@dev-guard/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/ai.d.ts +30 -0
- package/dist/ai.js +2050 -0
- package/dist/ai.js.map +1 -0
- package/dist/analyze.d.ts +2 -0
- package/dist/analyze.js +303 -0
- package/dist/analyze.js.map +1 -0
- package/dist/completion.d.ts +8 -0
- package/dist/completion.js +272 -0
- package/dist/completion.js.map +1 -0
- package/dist/context-files.d.ts +5 -0
- package/dist/context-files.js +44 -0
- package/dist/context-files.js.map +1 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.js +46 -0
- package/dist/defaults.js.map +1 -0
- package/dist/diff-intent.d.ts +19 -0
- package/dist/diff-intent.js +517 -0
- package/dist/diff-intent.js.map +1 -0
- package/dist/drift.d.ts +19 -0
- package/dist/drift.js +264 -0
- package/dist/drift.js.map +1 -0
- package/dist/index-check.mjs +13 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +633 -0
- package/dist/prompt.js.map +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +90 -0
- package/dist/report.js.map +1 -0
- package/dist/review.d.ts +4 -0
- package/dist/review.js +350 -0
- package/dist/review.js.map +1 -0
- package/dist/scan.d.ts +9 -0
- package/dist/scan.js +322 -0
- package/dist/scan.js.map +1 -0
- package/dist/task-anchor.d.ts +21 -0
- package/dist/task-anchor.js +126 -0
- package/dist/task-anchor.js.map +1 -0
- package/dist/task-router.d.ts +3 -0
- package/dist/task-router.js +340 -0
- package/dist/task-router.js.map +1 -0
- package/dist/templates.d.ts +20 -0
- package/dist/templates.js +56 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +366 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/update.d.ts +2 -0
- package/dist/update.js +250 -0
- package/dist/update.js.map +1 -0
- package/package.json +30 -0
package/dist/ai.js
ADDED
|
@@ -0,0 +1,2050 @@
|
|
|
1
|
+
import { classifyTaskType, taskTypeStrategyNotes } from "./task-router.js";
|
|
2
|
+
import { buildTaskCompletionCriteria, formatCompletionCriteria } from "./completion.js";
|
|
3
|
+
import { analyzeSemanticDrift, defaultContextPriority } from "./drift.js";
|
|
4
|
+
export class NoneAIProvider {
|
|
5
|
+
name = "none";
|
|
6
|
+
async generateText() {
|
|
7
|
+
throw new Error("AI provider is set to none. Configure it with `dev-guard configure ai --provider openai --model gpt-4o-mini`.");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class OpenAIProvider {
|
|
11
|
+
options;
|
|
12
|
+
name = "openai";
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
async generateText(input) {
|
|
17
|
+
const response = await fetch(`${this.options.baseURL ?? "https://api.openai.com/v1"}/responses`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
21
|
+
"Content-Type": "application/json"
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
model: input.model ?? this.options.model,
|
|
25
|
+
instructions: input.system,
|
|
26
|
+
input: input.prompt,
|
|
27
|
+
temperature: input.temperature ?? this.options.temperature,
|
|
28
|
+
max_output_tokens: input.maxTokens ?? this.options.maxTokens,
|
|
29
|
+
reasoning: (input.reasoningEffort ?? this.options.reasoningEffort)
|
|
30
|
+
? { effort: input.reasoningEffort ?? this.options.reasoningEffort }
|
|
31
|
+
: undefined
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
const json = (await response.json());
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(json.error?.message ?? `OpenAI API request failed with status ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
return extractResponseText(json);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function generateTaskMarkdown(provider, context, model) {
|
|
42
|
+
return (await generateTaskMarkdownResult(provider, context, model)).markdown;
|
|
43
|
+
}
|
|
44
|
+
export async function generateTaskMarkdownResult(provider, context, model) {
|
|
45
|
+
const fileCandidates = context.fileCandidates ?? analyzeFileRelevance(context.requirement, context.projectFiles ?? []);
|
|
46
|
+
const relatedFileCandidates = context.relatedFileCandidates ?? fileCandidates.filter((candidate) => candidate.role !== "ignored").map((candidate) => candidate.path);
|
|
47
|
+
const firstText = await provider.generateText({
|
|
48
|
+
model,
|
|
49
|
+
system: taskAISystemPrompt(),
|
|
50
|
+
prompt: buildTaskAIPrompt(context)
|
|
51
|
+
});
|
|
52
|
+
let taskMarkdown = ensureTaskMarkdownSections(firstText);
|
|
53
|
+
if (hasUnsupportedEmailPasswordSpeculation(context.requirement, taskMarkdown)) {
|
|
54
|
+
const retryText = await provider.generateText({
|
|
55
|
+
model,
|
|
56
|
+
system: taskAISystemPrompt(),
|
|
57
|
+
prompt: `${buildTaskAIPrompt(context)}
|
|
58
|
+
|
|
59
|
+
이전 응답에는 사용자 요구사항에 없는 이메일/비밀번호 추측이 포함되었습니다.
|
|
60
|
+
카카오 로그인 요구사항을 이메일/비밀번호 문제로 바꾸지 말고 다시 생성하세요.
|
|
61
|
+
관련 파일이 불명확하면 "현재 코드 확인 필요" 또는 "관련 파일 확인 필요"라고 쓰세요.`
|
|
62
|
+
});
|
|
63
|
+
taskMarkdown = ensureTaskMarkdownSections(retryText);
|
|
64
|
+
}
|
|
65
|
+
if (hasUnsupportedEmailPasswordSpeculation(context.requirement, taskMarkdown)) {
|
|
66
|
+
throw new Error("AI output included unsupported email/password speculation for a Kakao login requirement. No file was written.");
|
|
67
|
+
}
|
|
68
|
+
return postProcessTaskMarkdown(taskMarkdown, context, relatedFileCandidates);
|
|
69
|
+
}
|
|
70
|
+
export function inferRelatedFileCandidates(requirement, projectFiles) {
|
|
71
|
+
return analyzeFileRelevance(requirement, projectFiles)
|
|
72
|
+
.filter((candidate) => candidate.role === "edit" || candidate.role === "reference")
|
|
73
|
+
.map((candidate) => candidate.path);
|
|
74
|
+
}
|
|
75
|
+
export function analyzeFileRelevance(requirement, projectFiles, metadata = {}) {
|
|
76
|
+
const explicitRoutes = inferExplicitRouteTargets(requirement);
|
|
77
|
+
const tokens = extractRelevanceTokens(requirement);
|
|
78
|
+
const indexByPath = new Map((metadata.index ?? []).map((entry) => [entry.path, entry]));
|
|
79
|
+
const summaryByPath = new Map((metadata.summaries ?? []).map((summary) => [summary.path, summary]));
|
|
80
|
+
const graphByPath = new Map((metadata.codeGraph ?? []).map((entry) => [entry.file, entry]));
|
|
81
|
+
const runTargets = new Set((metadata.runTargetFiles ?? []).filter((file) => requestMatchesFile(requirement, file)));
|
|
82
|
+
const candidateByPath = new Map();
|
|
83
|
+
const seen = new Set(projectFiles);
|
|
84
|
+
for (const route of explicitRoutes.modifyTargets) {
|
|
85
|
+
if (!seen.has(route.path)) {
|
|
86
|
+
seen.add(route.path);
|
|
87
|
+
projectFiles = [...projectFiles, route.path];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const file of projectFiles) {
|
|
91
|
+
const index = indexByPath.get(file);
|
|
92
|
+
const summary = summaryByPath.get(file);
|
|
93
|
+
const graph = graphByPath.get(file);
|
|
94
|
+
const scored = scoreFileRelevance(file, requirement, tokens, explicitRoutes, { index, summary, runTargets, graph });
|
|
95
|
+
candidateByPath.set(file, scored);
|
|
96
|
+
}
|
|
97
|
+
applyGraphImpactScoring(candidateByPath, graphByPath, tokens);
|
|
98
|
+
return [...candidateByPath.values()]
|
|
99
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
|
100
|
+
.slice(0, 30);
|
|
101
|
+
}
|
|
102
|
+
export function extractTaskAIKeywords(requirement) {
|
|
103
|
+
const normalized = requirement.toLowerCase();
|
|
104
|
+
const keywords = new Set();
|
|
105
|
+
const keywordGroups = [
|
|
106
|
+
["settings", "설정"],
|
|
107
|
+
["admin", "관리자"],
|
|
108
|
+
["feedback", "의견", "피드백"],
|
|
109
|
+
["kakao", "카카오"],
|
|
110
|
+
["login", "로그인"],
|
|
111
|
+
["auth", "인증"],
|
|
112
|
+
["previous", "prev", "back", "이전", "뒤로"],
|
|
113
|
+
["navigation", "navigate", "경로", "이동"],
|
|
114
|
+
["state", "상태", "유지"],
|
|
115
|
+
["question", "질문", "문제", "문항"],
|
|
116
|
+
["dashboard", "대시보드"],
|
|
117
|
+
["home", "홈"],
|
|
118
|
+
["tree", "트리"],
|
|
119
|
+
["leaf", "잎"],
|
|
120
|
+
["theme", "테마"],
|
|
121
|
+
["dark", "다크"],
|
|
122
|
+
["light", "라이트"],
|
|
123
|
+
["supabase"]
|
|
124
|
+
];
|
|
125
|
+
for (const group of keywordGroups) {
|
|
126
|
+
if (group.some((keyword) => normalized.includes(keyword.toLowerCase()))) {
|
|
127
|
+
for (const keyword of group) {
|
|
128
|
+
keywords.add(keyword);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const token of extractRequirementTokens(requirement)) {
|
|
133
|
+
keywords.add(token);
|
|
134
|
+
}
|
|
135
|
+
return [...keywords].slice(0, 20);
|
|
136
|
+
}
|
|
137
|
+
export function buildTaskAIPrompt(context) {
|
|
138
|
+
const taskType = context.taskType ?? classifyTaskType(context.requirement);
|
|
139
|
+
const completionCriteria = context.completionCriteria ?? buildTaskCompletionCriteria(taskType);
|
|
140
|
+
const fileCandidates = context.fileCandidates ?? analyzeFileRelevance(context.requirement, context.projectFiles ?? []);
|
|
141
|
+
const relatedFileCandidates = context.relatedFileCandidates ?? fileCandidates.filter((candidate) => candidate.role !== "ignored").map((candidate) => candidate.path);
|
|
142
|
+
return `사용자 요구사항:
|
|
143
|
+
${context.requirement}
|
|
144
|
+
|
|
145
|
+
Context Priority:
|
|
146
|
+
${formatContextPriority()}
|
|
147
|
+
|
|
148
|
+
Requirement Anchor:
|
|
149
|
+
- 원문 requirement를 최상위 기준으로 고정하세요.
|
|
150
|
+
- 목표, 현재 문제, 완료 조건은 반드시 이 원문과 같은 주제를 말해야 합니다.
|
|
151
|
+
- 아래 프로젝트 상태, 결정, cache, 후보 파일은 참고 자료일 뿐이며 requirement를 덮어쓸 수 없습니다.
|
|
152
|
+
- 이전 run/task/docs에 있는 목표나 문장을 현재 requirement와 직접 관련 없으면 사용하지 마세요.
|
|
153
|
+
- navigation/back/previous/question/state 문제를 text cleanup/result wording 작업으로 바꾸지 마세요.
|
|
154
|
+
- text/result wording 요청을 navigation/state bugfix로 바꾸지 마세요.
|
|
155
|
+
|
|
156
|
+
프로젝트 규칙:
|
|
157
|
+
${context.rulesMarkdown || "(비어 있음)"}
|
|
158
|
+
|
|
159
|
+
반복하면 안 되는 실수:
|
|
160
|
+
${context.mistakesMarkdown || "(비어 있음)"}
|
|
161
|
+
|
|
162
|
+
프로젝트 상태:
|
|
163
|
+
${context.projectStateMarkdown || "(비어 있음)"}
|
|
164
|
+
|
|
165
|
+
최근 결정:
|
|
166
|
+
${context.decisionsMarkdown || "(비어 있음)"}
|
|
167
|
+
|
|
168
|
+
작업 유형:
|
|
169
|
+
${formatTaskType(taskType)}
|
|
170
|
+
|
|
171
|
+
완료 기준/리뷰 기준:
|
|
172
|
+
${formatCompletionCriteria(completionCriteria)}
|
|
173
|
+
|
|
174
|
+
현재 변경 파일:
|
|
175
|
+
${formatChangedFiles(context)}
|
|
176
|
+
|
|
177
|
+
프로젝트 파일 목록 요약:
|
|
178
|
+
${formatProjectFiles(context.projectFiles ?? [])}
|
|
179
|
+
|
|
180
|
+
키워드 기반 관련 파일 후보:
|
|
181
|
+
${formatListOrNeedsCheck(relatedFileCandidates)}
|
|
182
|
+
|
|
183
|
+
점수 기반 후보 분리:
|
|
184
|
+
${formatScoredCandidates(fileCandidates)}
|
|
185
|
+
|
|
186
|
+
영향도 힌트:
|
|
187
|
+
${formatTaskImpactHints(context.impactHints ?? [])}
|
|
188
|
+
|
|
189
|
+
관련 코드 컨텍스트:
|
|
190
|
+
${formatCodeContexts(context.codeContexts ?? [])}
|
|
191
|
+
|
|
192
|
+
diff 요약:
|
|
193
|
+
${context.diffText || "(diff 본문 없음)"}
|
|
194
|
+
|
|
195
|
+
명시 route 기반 대상 분리:
|
|
196
|
+
${formatExplicitRouteGuidance(context.requirement, relatedFileCandidates)}
|
|
197
|
+
|
|
198
|
+
task.md 생성 규칙:
|
|
199
|
+
- "사용자 요구사항 해석" 섹션을 반드시 포함하세요.
|
|
200
|
+
- "사용자 요구사항 해석"에는 원문, 해석, 작업 유형, 이 작업이 아닌 것을 짧게 쓰세요.
|
|
201
|
+
- "이 작업이 아닌 것"에는 현재 requirement와 혼동하기 쉬운 이전 문맥/다른 작업을 배제하세요.
|
|
202
|
+
- "작업 유형" 섹션을 반드시 포함하고, 아래 task type strategy를 파일 후보보다 우선하세요.
|
|
203
|
+
${taskTypeStrategyNotes(taskType).map((note) => `- ${note}`).join("\n")}
|
|
204
|
+
- "완료 기준" 섹션을 반드시 포함하고, 빌드 성공만 완료 기준으로 쓰지 마세요.
|
|
205
|
+
- 완료 기준에는 작업 결과에서 반드시 관찰되어야 할 상태와 blocking failure를 포함하세요.
|
|
206
|
+
- i18n/ui_text_cleanup 계열은 수정 전에 사용자 노출 문자열 inventory를 만든다는 기준을 포함하세요.
|
|
207
|
+
- 현재 사용자 요구사항을 최우선 기준으로 삼으세요.
|
|
208
|
+
- 기존 task.md, run 기록, 프로젝트 문서의 오래된 목표를 복사하지 말고 현재 사용자 요청을 기준으로 새 task.md를 재작성하세요.
|
|
209
|
+
- 현재 요청이 기존에 추가된 기능의 UI 개선, 자연스러운 통합, 어색함 제거라면 목표를 "기능 추가"가 아니라 "기존 UI의 자연스러운 통합/개선"으로 작성하세요.
|
|
210
|
+
- 새 요청과 이전 컨텍스트가 충돌하면 새 요청을 따르세요.
|
|
211
|
+
- userRequest/task/rules/run context에 없는 이해관계자를 만들지 마세요. 예: "디자이너의 요구", "PM 요구사항", "QA 피드백", "사용자 조사 결과", "브랜드 정책" 같은 표현은 사용자가 말한 경우에만 쓰세요.
|
|
212
|
+
- UI-only 요청은 기능 추가가 아니라 배치/문구/시각적 위계/기존 흐름 통합으로 해석하세요.
|
|
213
|
+
- UI-only 요청에서는 로직, 상태 관리, 저장/복원, fetch/auth, 결과 계산, routing 변경을 기본적으로 보호 대상으로 분리하세요.
|
|
214
|
+
- copy cleanup과 "전체 보기/누르면/열기/펼치기/모달/바텀시트" 같은 작은 UI interaction이 함께 있으면 copy-only가 아니라 scoped UI change로 작성하세요.
|
|
215
|
+
- scoped UI change에서는 클릭 이벤트, 선택 상태, local UI state, modal/right sheet/bottom sheet/inline expand를 허용하되 데이터 구조/API/auth/Supabase/OpenAI 연동은 보호하세요.
|
|
216
|
+
- scoped UI change에서는 실제 렌더 경로를 먼저 확인하고, 사용되지 않는 old component를 단일 수정 대상으로 고정하지 마세요.
|
|
217
|
+
- "현재 문제"는 사용자 원문 요구사항에 근거해서만 작성하세요.
|
|
218
|
+
- 코드에 근거 없는 원인 추측을 하지 마세요.
|
|
219
|
+
- 파일을 확인하지 못했거나 후보가 불충분하면 "현재 코드 확인 필요" 또는 "관련 파일 확인 필요"라고 적으세요.
|
|
220
|
+
- 사용자 요구사항에 없는 문제를 만들어내지 마세요.
|
|
221
|
+
- 특히 카카오 로그인 요구사항을 이메일/비밀번호 문제로 바꾸지 마세요.
|
|
222
|
+
- 반드시 "수정 대상", "참고 대상", "보호 대상"을 분리하세요.
|
|
223
|
+
- "수정 대상"은 실제 수정/삭제/생성할 파일만 넣으세요.
|
|
224
|
+
- "참고 대상"은 UI/구조를 참고할 수 있지만 직접 수정하지 않는 파일을 넣으세요.
|
|
225
|
+
- "보호 대상"은 건드리면 안 되는 실제 기능/데이터/인증 파일을 넣으세요.
|
|
226
|
+
- 점수 기반 후보 분리에서 role=edit인 파일은 특별한 반대 근거가 없으면 "수정 대상"에 포함하세요.
|
|
227
|
+
- role=reference인 파일은 "참고 대상"에 넣고 기본 수정 대상으로 올리지 마세요.
|
|
228
|
+
- role=protected인 파일은 "보호 대상"에 넣고 수정하지 마세요.
|
|
229
|
+
- role=ignored인 파일은 task.md에 넣지 마세요.
|
|
230
|
+
- edit candidate를 임의로 reference로 낮추지 마세요. 낮출 경우 이유를 Codex 주의사항에 쓰세요.
|
|
231
|
+
- "그대로 사용"은 기존 기능 파일을 수정하라는 뜻이 아닐 수 있습니다. 기존 UI를 참고하거나 preview/mock으로 재현한다는 의미인지 먼저 보수적으로 해석하세요.
|
|
232
|
+
- public/about/service/screening/소개용 페이지에서는 실제 기능 로직을 건드리지 말고 about/service 전용 preview/mock 구조를 우선 제안하세요.
|
|
233
|
+
- 사용자 요청에 명시된 route는 반드시 "수정 대상"과 "수정 범위"에 우선 반영하세요.
|
|
234
|
+
- 삭제 요청이 있는 route는 "삭제 대상"으로 명시하세요.
|
|
235
|
+
- 관련 파일 후보가 제공된 경우 "수정 범위"에 반드시 후보 파일 경로를 포함하세요.
|
|
236
|
+
- 확실하지 않으면 파일명 뒤에 "(후보)"를 붙이세요.
|
|
237
|
+
- 후보가 있는데도 "관련 파일 확인 필요"만 단독으로 쓰지 마세요.
|
|
238
|
+
- 관련 코드 컨텍스트가 있으면 "수정 범위"에 구체적 파일 경로를 넣되, 근거가 약하면 "(후보)"라고 표시하세요.
|
|
239
|
+
- 코드 컨텍스트는 참고용입니다. 실제 수정 전 Codex가 파일을 직접 확인해야 한다고 주의사항에 적으세요.
|
|
240
|
+
- "수정 범위"는 관련 파일 후보 또는 코드 컨텍스트를 우선 사용하고, 확실하지 않으면 "관련 파일 확인 필요"로 표시하세요.
|
|
241
|
+
- "보호 대상"과 "건드리면 안 되는 것"은 실제 보호해야 할 로직/파일을 짧게 쓰세요. 문서 업데이트 정책 같은 generic 문장은 넣지 마세요.
|
|
242
|
+
- "완료 조건"은 사용자가 눈으로 확인할 수 있는 결과 중심으로 쓰세요.
|
|
243
|
+
- "검증 명령어"에는 항상 \`pnpm run build\`를 포함하세요.
|
|
244
|
+
- about/service, 심사, 서비스 화면, 샘플 데이터, preview, 실제 화면 그대로, 로그인하지 않아도 같은 키워드가 있으면 실제 기능 컴포넌트, Supabase/Auth/fetch 로직은 보호 대상으로 분리하세요.
|
|
245
|
+
- about/service 심사용/preview 작업에서는 app/auth/**, components/landing/*login*, components/landing/*start*, lib/auth/**, auth callback 파일을 수정 대상이나 수정 범위 후보에 넣지 말고 보호 대상에만 넣으세요.
|
|
246
|
+
- 명시 route가 확인된 경우 "현재 문제"에 "관련 파일 확인 필요" 같은 일반 문장을 반복하지 말고 route 정리 문제를 구체적으로 쓰세요.
|
|
247
|
+
- about/service 심사용/preview 작업의 완료 조건에는 /about/service 접근, /review/kakao 제거 또는 접근 불가, 로그인 없는 샘플 데이터 화면, 실제 데이터 fetch 없음, 실제 dashboard/home/auth 로직 직접 수정 없음, pnpm run build 성공을 포함하세요.
|
|
248
|
+
- "전체 영문 변환", "다국어", "i18n", "localization", "translation", "언어 전환" 같은 요청은 대규모 i18n 구조 도입 작업으로 해석하세요.
|
|
249
|
+
- i18n 작업은 기존 한국어 원본을 덮어쓰지 말고, 번역 리소스와 언어 전환 구조를 추가한 뒤 대표 화면 1개에만 샘플 적용하는 1단계 작업으로 분해하세요.
|
|
250
|
+
- i18n 작업에서는 전체 app/page 파일, admin/dashboard/settings/checklist 같은 전역 화면을 무차별 수정 대상으로 나열하지 마세요.
|
|
251
|
+
- i18n 작업 task.md에는 "1단계 목표", "이번 작업 범위", "이후 단계", "이번 작업에서 제외할 것" 섹션을 포함하세요.
|
|
252
|
+
- architecture/migration처럼 requiresPhasing=true인 작업은 "이번 단계", "이후 단계", "이번 작업에서 제외할 것"을 포함하세요.
|
|
253
|
+
- product_strategy 작업은 바로 코드 수정하지 말고 discovery-first product brief 작업으로 작성하세요.
|
|
254
|
+
- product_strategy 작업에서는 관련 파일을 "참고 대상"으로만 두고, "수정 대상"은 승인 전까지 없음으로 표시하세요.
|
|
255
|
+
- product_strategy 작업에는 "구현 전 정의해야 할 것" 섹션을 포함하고, 왜 써야 하는지/공유 이유/재미 요소/검증 기준/최소 구현 범위를 정의하게 하세요.
|
|
256
|
+
- product_strategy 작업에는 "추천 실험 방향" 섹션을 포함하고, implementation 이전 단계의 lightweight experiment proposal을 3~5개 compact하게 제안하세요.
|
|
257
|
+
- "추천 실험 방향"은 feature spec, UI redesign, 코드 수정 지시가 아닌 user reaction 중심의 실험 단위여야 합니다. "왜 공유하고 싶어지는지", "왜 다시 해보고 싶어지는지" 기준으로 작성하세요.
|
|
258
|
+
|
|
259
|
+
위 정보를 바탕으로 .devguard/task.md에 바로 저장할 Markdown만 생성하세요.`;
|
|
260
|
+
}
|
|
261
|
+
function taskAISystemPrompt() {
|
|
262
|
+
return [
|
|
263
|
+
"You generate precise task.md files for a rule-based developer guard CLI.",
|
|
264
|
+
"Return only Markdown. Do not wrap the result in code fences.",
|
|
265
|
+
"The Markdown must include these exact Korean section headings:",
|
|
266
|
+
"## 목표",
|
|
267
|
+
"## 사용자 요구사항 해석",
|
|
268
|
+
"## 작업 유형",
|
|
269
|
+
"## 현재 문제",
|
|
270
|
+
"## 수정 범위",
|
|
271
|
+
"## 수정 대상",
|
|
272
|
+
"## 참고 대상",
|
|
273
|
+
"## 보호 대상",
|
|
274
|
+
"## 반드시 지킬 규칙",
|
|
275
|
+
"## 건드리면 안 되는 것",
|
|
276
|
+
"## 완료 기준",
|
|
277
|
+
"## 완료 조건",
|
|
278
|
+
"## 검증 명령어",
|
|
279
|
+
"## Codex에게 전달할 주의사항",
|
|
280
|
+
"Keep the task narrow, concrete, and verifiable. Never include API keys or secrets.",
|
|
281
|
+
"The current user requirement is the highest-priority source. Do not reuse an old task goal when the user asks for a new refinement.",
|
|
282
|
+
"Add a 사용자 요구사항 해석 section and keep every generated section anchored to the current raw requirement.",
|
|
283
|
+
"If cached context conflicts with the requirement, ignore the cached context.",
|
|
284
|
+
"For UI refinement requests about already-added behavior, frame the goal as natural integration or polish, not adding the feature again.",
|
|
285
|
+
"Do not invent stakeholders such as designer, PM, QA, user research, or brand policy unless explicitly present in the user request or context.",
|
|
286
|
+
"For UI-only tasks, protect logic, state, storage/restore, fetch/auth, result calculation, routing, animation state, cache, providers, and layout shells unless explicitly requested.",
|
|
287
|
+
"For ui_feature_polish tasks, allow scoped UI interactions such as click handlers, local component state, modal/sheet/inline expand, but protect data model, persistence, API, auth, and server integrations.",
|
|
288
|
+
"Always include pnpm run build in verification commands.",
|
|
289
|
+
"For product_strategy tasks, do not start code edits. Generate a discovery-first brief with reference files only, no primary edit targets until scope is approved.",
|
|
290
|
+
"Do not invent causes that are not supported by the provided project files or user requirement.",
|
|
291
|
+
"If the relevant file is unclear, write 확인 필요 instead of guessing.",
|
|
292
|
+
"Do not transform social-login requirements into email/password issues unless the user explicitly says email/password.",
|
|
293
|
+
"Separate modification targets, reference-only files, and protected files.",
|
|
294
|
+
"Explicit routes in the user requirement must take priority over fuzzy file candidates.",
|
|
295
|
+
"For about/service or screening/preview pages, prefer mock/sample-data preview components and protect real Auth/Supabase/data-fetching logic.",
|
|
296
|
+
"For whole-project English, multilingual, i18n, localization, locale, or translation requests, decompose the work into a first structural step. Preserve existing Korean copy, add translation resources, and apply only one representative screen as a sample."
|
|
297
|
+
].join("\n");
|
|
298
|
+
}
|
|
299
|
+
function formatChangedFiles(context) {
|
|
300
|
+
const files = context.changeFiles?.length
|
|
301
|
+
? context.changeFiles.map((file) => `${file.path} (${file.source}/${file.status})`)
|
|
302
|
+
: context.changedFiles;
|
|
303
|
+
if (files.length === 0) {
|
|
304
|
+
return "- 변경 파일 없음";
|
|
305
|
+
}
|
|
306
|
+
return files.map((file) => `- ${file}`).join("\n");
|
|
307
|
+
}
|
|
308
|
+
function formatTaskType(taskType) {
|
|
309
|
+
return [
|
|
310
|
+
`- type: ${taskType.type}`,
|
|
311
|
+
`- confidence: ${taskType.confidence}`,
|
|
312
|
+
`- strategy: ${taskType.strategy}`,
|
|
313
|
+
`- risk: ${taskType.riskLevel}`,
|
|
314
|
+
`- requires phasing: ${taskType.requiresPhasing ? "true" : "false"}`,
|
|
315
|
+
`- reasons: ${taskType.reasons.length > 0 ? taskType.reasons.join("; ") : "none"}`
|
|
316
|
+
].join("\n");
|
|
317
|
+
}
|
|
318
|
+
function formatContextPriority() {
|
|
319
|
+
const priority = defaultContextPriority();
|
|
320
|
+
return [
|
|
321
|
+
`- requirement: ${priority.requirement}`,
|
|
322
|
+
`- relatedCode: ${priority.relatedCode}`,
|
|
323
|
+
`- taskSubtypeContext: ${priority.taskSubtypeContext}`,
|
|
324
|
+
`- recentRun: ${priority.recentRun}`,
|
|
325
|
+
`- projectMemory: ${priority.projectMemory}`,
|
|
326
|
+
`- staleDocs: ${priority.staleDocs}`,
|
|
327
|
+
"- rule: Requirement > Current code context > subtype context > previous runs/docs"
|
|
328
|
+
].join("\n");
|
|
329
|
+
}
|
|
330
|
+
function extractResponseText(json) {
|
|
331
|
+
if (json.output_text?.trim()) {
|
|
332
|
+
return json.output_text.trim();
|
|
333
|
+
}
|
|
334
|
+
const text = json.output
|
|
335
|
+
?.flatMap((item) => item.content ?? [])
|
|
336
|
+
.map((content) => content.text ?? "")
|
|
337
|
+
.join("")
|
|
338
|
+
.trim();
|
|
339
|
+
if (!text) {
|
|
340
|
+
throw new Error("OpenAI API response did not include text output.");
|
|
341
|
+
}
|
|
342
|
+
return text;
|
|
343
|
+
}
|
|
344
|
+
function formatProjectFiles(projectFiles) {
|
|
345
|
+
if (projectFiles.length === 0) {
|
|
346
|
+
return "- 프로젝트 파일 목록 없음";
|
|
347
|
+
}
|
|
348
|
+
return projectFiles.slice(0, 200).map((file) => `- ${file}`).join("\n");
|
|
349
|
+
}
|
|
350
|
+
function formatListOrNeedsCheck(items) {
|
|
351
|
+
if (items.length === 0) {
|
|
352
|
+
return "- 관련 파일 확인 필요";
|
|
353
|
+
}
|
|
354
|
+
return items.map((item) => `- ${item}`).join("\n");
|
|
355
|
+
}
|
|
356
|
+
function formatScoredCandidates(candidates) {
|
|
357
|
+
if (candidates.length === 0) {
|
|
358
|
+
return "- 후보 없음";
|
|
359
|
+
}
|
|
360
|
+
const groups = [
|
|
361
|
+
["edit candidates", candidates.filter((candidate) => candidate.role === "edit")],
|
|
362
|
+
["reference candidates", candidates.filter((candidate) => candidate.role === "reference")],
|
|
363
|
+
["protected candidates", candidates.filter((candidate) => candidate.role === "file-protected" || candidate.role === "logic-protected" || candidate.role === "protected")]
|
|
364
|
+
];
|
|
365
|
+
return groups
|
|
366
|
+
.map(([title, items]) => {
|
|
367
|
+
const body = items.length > 0
|
|
368
|
+
? items
|
|
369
|
+
.slice(0, 8)
|
|
370
|
+
.map((candidate) => {
|
|
371
|
+
const reasons = candidate.reasons.length > 0 ? candidate.reasons.join("; ") : "no positive reason";
|
|
372
|
+
const negative = candidate.negativeReasons.length > 0 ? ` negative: ${candidate.negativeReasons.join("; ")}` : "";
|
|
373
|
+
return `- ${candidate.path} (score ${candidate.score}) reasons: ${reasons}${negative}`;
|
|
374
|
+
})
|
|
375
|
+
.join("\n")
|
|
376
|
+
: "- 없음";
|
|
377
|
+
return `### ${title}\n${body}`;
|
|
378
|
+
})
|
|
379
|
+
.join("\n\n");
|
|
380
|
+
}
|
|
381
|
+
function formatCodeContexts(codeContexts) {
|
|
382
|
+
if (codeContexts.length === 0) {
|
|
383
|
+
return "- 관련 코드 컨텍스트 없음";
|
|
384
|
+
}
|
|
385
|
+
return codeContexts
|
|
386
|
+
.map((context) => {
|
|
387
|
+
const keywords = context.matchedKeywords.length > 0 ? context.matchedKeywords.join(", ") : "키워드 직접 발견 없음";
|
|
388
|
+
const truncated = context.truncated ? "yes" : "no";
|
|
389
|
+
return `### ${context.path}\n- 발견 키워드: ${keywords}\n- truncated: ${truncated}\n\n\`\`\`\n${context.excerpt}\n\`\`\``;
|
|
390
|
+
})
|
|
391
|
+
.join("\n\n");
|
|
392
|
+
}
|
|
393
|
+
function formatTaskImpactHints(impactHints) {
|
|
394
|
+
if (impactHints.length === 0) {
|
|
395
|
+
return "- 영향도 힌트 없음";
|
|
396
|
+
}
|
|
397
|
+
return impactHints
|
|
398
|
+
.slice(0, 5)
|
|
399
|
+
.map((hint) => `- ${hint.file}: imported by ${hint.importedByCount}; affected ${hint.affectedAreas.join(", ") || "unknown"}`)
|
|
400
|
+
.join("\n");
|
|
401
|
+
}
|
|
402
|
+
function extractRequirementTokens(requirement) {
|
|
403
|
+
return [...new Set(requirement.toLowerCase().match(/[a-z0-9가-힣]{3,}/g) ?? [])].slice(0, 12);
|
|
404
|
+
}
|
|
405
|
+
function extractRelevanceTokens(requirement) {
|
|
406
|
+
const tokens = new Set(extractRequirementTokens(requirement));
|
|
407
|
+
const groups = [
|
|
408
|
+
["result", "결과"],
|
|
409
|
+
["question", "질문", "문항"],
|
|
410
|
+
["choice", "선택"],
|
|
411
|
+
["previous", "prev", "back", "이전", "뒤로"],
|
|
412
|
+
["navigation", "navigate", "이동", "경로"],
|
|
413
|
+
["state", "상태", "유지"],
|
|
414
|
+
["auth", "인증", "로그인", "login"],
|
|
415
|
+
["settings", "설정"],
|
|
416
|
+
["admin", "관리자"],
|
|
417
|
+
["feedback", "피드백", "의견"],
|
|
418
|
+
["theme", "테마", "다크", "dark", "light"],
|
|
419
|
+
["dashboard", "대시보드"],
|
|
420
|
+
["home", "홈"],
|
|
421
|
+
["tree", "트리"]
|
|
422
|
+
];
|
|
423
|
+
const lower = requirement.toLowerCase();
|
|
424
|
+
for (const group of groups) {
|
|
425
|
+
if (group.some((token) => lower.includes(token.toLowerCase()))) {
|
|
426
|
+
for (const token of group) {
|
|
427
|
+
tokens.add(token.toLowerCase());
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return [...tokens].filter((token) => token.length >= 2).slice(0, 24);
|
|
432
|
+
}
|
|
433
|
+
function scoreFileRelevance(file, requirement, tokens, explicitRoutes, metadata) {
|
|
434
|
+
const reasons = [];
|
|
435
|
+
const negativeReasons = [];
|
|
436
|
+
let score = scoreExplicitCandidate(file, explicitRoutes);
|
|
437
|
+
if (score > 0) {
|
|
438
|
+
reasons.push("explicit route match");
|
|
439
|
+
}
|
|
440
|
+
const pathTokens = tokenizePath(file);
|
|
441
|
+
const keywordTokens = new Set([...(metadata.index?.keywords ?? []), ...(metadata.summary?.keywords ?? [])].flatMap(tokenizeText));
|
|
442
|
+
const categoryTokens = new Set(tokenizeText(metadata.index?.category ?? ""));
|
|
443
|
+
const featureTokens = new Set((metadata.summary?.features ?? []).flatMap(tokenizeText));
|
|
444
|
+
const routeTokens = new Set(extractRouteSegments(file).flatMap(tokenizeText));
|
|
445
|
+
const exportTokens = new Set((metadata.graph?.exports ?? []).flatMap(tokenizeText));
|
|
446
|
+
const usageTokens = new Set((metadata.graph?.usageHints ?? []).flatMap(tokenizeText));
|
|
447
|
+
for (const token of tokens) {
|
|
448
|
+
if (pathTokens.has(token)) {
|
|
449
|
+
score += 12;
|
|
450
|
+
reasons.push(`path token:${token}`);
|
|
451
|
+
}
|
|
452
|
+
else if ([...pathTokens].some((pathToken) => pathToken.includes(token) || token.includes(pathToken))) {
|
|
453
|
+
score += 5;
|
|
454
|
+
reasons.push(`path partial:${token}`);
|
|
455
|
+
}
|
|
456
|
+
if (keywordTokens.has(token)) {
|
|
457
|
+
score += 8;
|
|
458
|
+
reasons.push(`summary keyword:${token}`);
|
|
459
|
+
}
|
|
460
|
+
if (categoryTokens.has(token)) {
|
|
461
|
+
score += 8;
|
|
462
|
+
reasons.push(`category:${token}`);
|
|
463
|
+
}
|
|
464
|
+
if (routeTokens.has(token)) {
|
|
465
|
+
score += 10;
|
|
466
|
+
reasons.push(`route segment:${token}`);
|
|
467
|
+
}
|
|
468
|
+
if (featureTokens.has(token)) {
|
|
469
|
+
score += 6;
|
|
470
|
+
reasons.push(`related feature:${token}`);
|
|
471
|
+
}
|
|
472
|
+
if (exportTokens.has(token)) {
|
|
473
|
+
score += 7;
|
|
474
|
+
reasons.push(`export:${token}`);
|
|
475
|
+
}
|
|
476
|
+
if (usageTokens.has(token)) {
|
|
477
|
+
score += 4;
|
|
478
|
+
reasons.push(`usage hint:${token}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (metadata.runTargets.has(file)) {
|
|
482
|
+
score += 6;
|
|
483
|
+
reasons.push("matching saved run target");
|
|
484
|
+
}
|
|
485
|
+
for (const conflict of conflictingConcepts(requirement)) {
|
|
486
|
+
if (pathTokens.has(conflict) || keywordTokens.has(conflict) || categoryTokens.has(conflict) || featureTokens.has(conflict)) {
|
|
487
|
+
score -= 16;
|
|
488
|
+
negativeReasons.push(`conflicting token:${conflict}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (shouldExcludeExplicitCandidate(file, explicitRoutes)) {
|
|
492
|
+
score -= 100;
|
|
493
|
+
negativeReasons.push("excluded by explicit route intent");
|
|
494
|
+
}
|
|
495
|
+
const role = score >= 12 ? "edit" : score >= 5 ? "reference" : score <= -20 ? "ignored" : "ignored";
|
|
496
|
+
return {
|
|
497
|
+
path: file,
|
|
498
|
+
score,
|
|
499
|
+
reasons: [...new Set(reasons)].slice(0, 8),
|
|
500
|
+
negativeReasons: [...new Set(negativeReasons)].slice(0, 6),
|
|
501
|
+
role
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function applyGraphImpactScoring(candidates, graphByPath, tokens) {
|
|
505
|
+
const directMatches = [...candidates.values()].filter((candidate) => candidate.score >= 12);
|
|
506
|
+
for (const candidate of directMatches) {
|
|
507
|
+
const graph = graphByPath.get(candidate.path);
|
|
508
|
+
if (!graph) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
for (const importer of graph.importedBy.slice(0, 12)) {
|
|
512
|
+
bumpGraphCandidate(candidates, importer, 7, `reverse dependency of ${candidate.path}`);
|
|
513
|
+
}
|
|
514
|
+
for (const imported of graph.imports.slice(0, 8)) {
|
|
515
|
+
bumpGraphCandidate(candidates, imported, 4, `dependency of ${candidate.path}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const [path, graph] of graphByPath.entries()) {
|
|
519
|
+
const haystack = [path, graph.category, ...graph.exports, ...graph.usageHints].join(" ").toLowerCase();
|
|
520
|
+
if (tokens.some((token) => haystack.includes(token))) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const sameCluster = [...candidates.values()].some((candidate) => {
|
|
524
|
+
const candidateGraph = graphByPath.get(candidate.path);
|
|
525
|
+
return candidate.score >= 12 && candidateGraph?.category === graph.category;
|
|
526
|
+
});
|
|
527
|
+
if (sameCluster) {
|
|
528
|
+
bumpGraphCandidate(candidates, path, 2, `same feature cluster:${graph.category}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function bumpGraphCandidate(candidates, path, delta, reason) {
|
|
533
|
+
const current = candidates.get(path) ??
|
|
534
|
+
{
|
|
535
|
+
path,
|
|
536
|
+
score: 0,
|
|
537
|
+
reasons: [],
|
|
538
|
+
negativeReasons: [],
|
|
539
|
+
role: "ignored"
|
|
540
|
+
};
|
|
541
|
+
current.score += delta;
|
|
542
|
+
current.reasons = [...new Set([...current.reasons, reason])].slice(0, 8);
|
|
543
|
+
if (current.score >= 12) {
|
|
544
|
+
current.role = "edit";
|
|
545
|
+
}
|
|
546
|
+
else if (current.score >= 5 && current.role !== "edit") {
|
|
547
|
+
current.role = "reference";
|
|
548
|
+
}
|
|
549
|
+
candidates.set(path, current);
|
|
550
|
+
}
|
|
551
|
+
function tokenizePath(path) {
|
|
552
|
+
return new Set(tokenizeText(path.replace(/\.[^.]+$/, "")));
|
|
553
|
+
}
|
|
554
|
+
function tokenizeText(text) {
|
|
555
|
+
return text
|
|
556
|
+
.toLowerCase()
|
|
557
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
558
|
+
.split(/[^a-z0-9가-힣]+/)
|
|
559
|
+
.filter((token) => token.length >= 2);
|
|
560
|
+
}
|
|
561
|
+
function extractRouteSegments(file) {
|
|
562
|
+
const parts = file.split("/");
|
|
563
|
+
const appIndex = parts.indexOf("app");
|
|
564
|
+
if (appIndex >= 0) {
|
|
565
|
+
return parts.slice(appIndex + 1, -1);
|
|
566
|
+
}
|
|
567
|
+
const pagesIndex = parts.indexOf("pages");
|
|
568
|
+
if (pagesIndex >= 0) {
|
|
569
|
+
return parts.slice(pagesIndex + 1, -1);
|
|
570
|
+
}
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
573
|
+
function conflictingConcepts(requirement) {
|
|
574
|
+
const lower = requirement.toLowerCase();
|
|
575
|
+
const conflicts = [];
|
|
576
|
+
const wantsResult = /result|결과/.test(lower);
|
|
577
|
+
const wantsQuestion = /question|choice|질문|문제|문항|선택/.test(lower);
|
|
578
|
+
const wantsNavigationState = /이전|뒤로|돌아가|previous|prev|back|navigation|navigate|state|상태|유지|바뀌/.test(lower);
|
|
579
|
+
if (wantsResult && !wantsQuestion) {
|
|
580
|
+
conflicts.push("question", "choice", "질문", "문항", "선택");
|
|
581
|
+
}
|
|
582
|
+
if ((wantsQuestion || wantsNavigationState) && !wantsResult) {
|
|
583
|
+
conflicts.push("result", "결과");
|
|
584
|
+
}
|
|
585
|
+
if (wantsNavigationState) {
|
|
586
|
+
conflicts.push("wording", "copy", "text", "문구", "텍스트", "표현");
|
|
587
|
+
}
|
|
588
|
+
return conflicts;
|
|
589
|
+
}
|
|
590
|
+
function requestMatchesFile(requirement, file) {
|
|
591
|
+
const fileTokens = tokenizePath(file);
|
|
592
|
+
return extractRelevanceTokens(requirement).some((token) => fileTokens.has(token));
|
|
593
|
+
}
|
|
594
|
+
function ensureTaskMarkdownSections(markdown) {
|
|
595
|
+
const requiredHeadings = [
|
|
596
|
+
"## 목표",
|
|
597
|
+
"## 사용자 요구사항 해석",
|
|
598
|
+
"## 작업 유형",
|
|
599
|
+
"## 현재 문제",
|
|
600
|
+
"## 수정 범위",
|
|
601
|
+
"## 수정 대상",
|
|
602
|
+
"## 참고 대상",
|
|
603
|
+
"## 보호 대상",
|
|
604
|
+
"## 반드시 지킬 규칙",
|
|
605
|
+
"## 건드리면 안 되는 것",
|
|
606
|
+
"## 완료 조건",
|
|
607
|
+
"## 검증 명령어",
|
|
608
|
+
"## Codex에게 전달할 주의사항"
|
|
609
|
+
];
|
|
610
|
+
const trimmed = markdown.replace(/^```(?:md|markdown)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
611
|
+
const missing = requiredHeadings.filter((heading) => !trimmed.includes(heading));
|
|
612
|
+
if (missing.length === 0) {
|
|
613
|
+
return `${trimmed}\n`;
|
|
614
|
+
}
|
|
615
|
+
return `${trimmed}\n\n${missing
|
|
616
|
+
.map((heading) => `${heading}\n- TODO: AI output omitted this required section. Fill this before handing to Codex.`)
|
|
617
|
+
.join("\n\n")}\n`;
|
|
618
|
+
}
|
|
619
|
+
function postProcessTaskMarkdown(markdown, context, candidates) {
|
|
620
|
+
const explicitRoutes = inferExplicitRouteTargets(context.requirement);
|
|
621
|
+
const taskType = context.taskType ?? classifyTaskType(context.requirement);
|
|
622
|
+
const i18nTask = taskType.type === "i18n";
|
|
623
|
+
let nextMarkdown = sanitizeHallucinatedTaskText(markdown);
|
|
624
|
+
let changed = false;
|
|
625
|
+
const taskTypeResult = applyTaskTypeSection(nextMarkdown, taskType);
|
|
626
|
+
nextMarkdown = taskTypeResult.markdown;
|
|
627
|
+
changed ||= taskTypeResult.changed;
|
|
628
|
+
const interpretationResult = applyRequirementInterpretationSection(nextMarkdown, context);
|
|
629
|
+
nextMarkdown = interpretationResult.markdown;
|
|
630
|
+
changed ||= interpretationResult.changed;
|
|
631
|
+
const criteriaResult = applyCompletionCriteriaSection(nextMarkdown, context.completionCriteria ?? buildTaskCompletionCriteria(taskType));
|
|
632
|
+
nextMarkdown = criteriaResult.markdown;
|
|
633
|
+
changed ||= criteriaResult.changed;
|
|
634
|
+
const currentRequestResult = applyCurrentRequestPrioritySections(nextMarkdown, context.requirement);
|
|
635
|
+
nextMarkdown = currentRequestResult.markdown;
|
|
636
|
+
changed ||= currentRequestResult.changed;
|
|
637
|
+
if (i18nTask) {
|
|
638
|
+
const i18nResult = applyI18nTaskDecomposition(nextMarkdown, context, taskType);
|
|
639
|
+
nextMarkdown = i18nResult.markdown;
|
|
640
|
+
changed ||= i18nResult.changed;
|
|
641
|
+
const compactResult = compactRedundantTaskSections(nextMarkdown);
|
|
642
|
+
nextMarkdown = compactResult.markdown;
|
|
643
|
+
changed ||= compactResult.changed;
|
|
644
|
+
return {
|
|
645
|
+
markdown: nextMarkdown,
|
|
646
|
+
scopeFilledFromCandidates: changed
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
if (taskType.type === "ui_feature_polish") {
|
|
651
|
+
const featurePolishResult = applyUiFeaturePolishSections(nextMarkdown, context, candidates);
|
|
652
|
+
nextMarkdown = featurePolishResult.markdown;
|
|
653
|
+
changed ||= featurePolishResult.changed;
|
|
654
|
+
const compactResult = compactRedundantTaskSections(nextMarkdown);
|
|
655
|
+
nextMarkdown = compactResult.markdown;
|
|
656
|
+
changed ||= compactResult.changed;
|
|
657
|
+
return {
|
|
658
|
+
markdown: nextMarkdown,
|
|
659
|
+
scopeFilledFromCandidates: changed
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (taskType.type === "product_strategy") {
|
|
663
|
+
const productResult = applyProductStrategySections(nextMarkdown, context, candidates);
|
|
664
|
+
nextMarkdown = productResult.markdown;
|
|
665
|
+
changed ||= productResult.changed;
|
|
666
|
+
const compactResult = compactRedundantTaskSections(nextMarkdown);
|
|
667
|
+
nextMarkdown = compactResult.markdown;
|
|
668
|
+
changed ||= compactResult.changed;
|
|
669
|
+
return {
|
|
670
|
+
markdown: nextMarkdown,
|
|
671
|
+
scopeFilledFromCandidates: changed
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const scopeResult = fillScopeFromCandidates(nextMarkdown, candidates, explicitRoutes);
|
|
675
|
+
nextMarkdown = scopeResult.markdown;
|
|
676
|
+
changed ||= scopeResult.scopeFilledFromCandidates;
|
|
677
|
+
const targetResult = applyExplicitTargetSections(nextMarkdown, explicitRoutes, candidates);
|
|
678
|
+
nextMarkdown = targetResult.markdown;
|
|
679
|
+
changed ||= targetResult.changed;
|
|
680
|
+
const directTargetResult = applyDirectModificationTargets(nextMarkdown, context.requirement, candidates);
|
|
681
|
+
nextMarkdown = directTargetResult.markdown;
|
|
682
|
+
changed ||= directTargetResult.changed;
|
|
683
|
+
const qualityResult = applyIntentQualitySections(nextMarkdown, explicitRoutes);
|
|
684
|
+
nextMarkdown = qualityResult.markdown;
|
|
685
|
+
changed ||= qualityResult.changed;
|
|
686
|
+
if (taskType.requiresPhasing) {
|
|
687
|
+
const phasingResult = applyGenericPhasingSections(nextMarkdown, taskType);
|
|
688
|
+
nextMarkdown = phasingResult.markdown;
|
|
689
|
+
changed ||= phasingResult.changed;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const safetyResult = applyGeneralTaskQualitySections(nextMarkdown, context, candidates);
|
|
693
|
+
nextMarkdown = safetyResult.markdown;
|
|
694
|
+
changed ||= safetyResult.changed;
|
|
695
|
+
const normalizationResult = normalizeTaskTargetProtectionSeparation(nextMarkdown);
|
|
696
|
+
nextMarkdown = normalizationResult.markdown;
|
|
697
|
+
changed ||= normalizationResult.changed;
|
|
698
|
+
const mismatchResult = applyRequirementMismatchGuard(nextMarkdown, context, candidates);
|
|
699
|
+
nextMarkdown = mismatchResult.markdown;
|
|
700
|
+
changed ||= mismatchResult.changed;
|
|
701
|
+
const compactResult = compactRedundantTaskSections(nextMarkdown);
|
|
702
|
+
nextMarkdown = compactResult.markdown;
|
|
703
|
+
changed ||= compactResult.changed;
|
|
704
|
+
return {
|
|
705
|
+
markdown: nextMarkdown,
|
|
706
|
+
scopeFilledFromCandidates: changed
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function compactRedundantTaskSections(markdown) {
|
|
710
|
+
let nextMarkdown = markdown;
|
|
711
|
+
let changed = false;
|
|
712
|
+
const protectedLines = sectionLines(readMarkdownSection(nextMarkdown, "보호 대상"));
|
|
713
|
+
for (const section of ["건드리면 안 되는 것", "Codex에게 전달할 주의사항"]) {
|
|
714
|
+
const lines = sectionLines(readMarkdownSection(nextMarkdown, section));
|
|
715
|
+
const compacted = removeDuplicateMeaning(lines, protectedLines);
|
|
716
|
+
if (compacted.join("\n") !== lines.join("\n")) {
|
|
717
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, section, compacted.join("\n") || "- 보호 대상에 적힌 로직/영역을 건드리지 않는다.");
|
|
718
|
+
changed = true;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const criteriaLines = sectionLines(readMarkdownSection(nextMarkdown, "완료 기준"));
|
|
722
|
+
const conditionLines = sectionLines(readMarkdownSection(nextMarkdown, "완료 조건"));
|
|
723
|
+
const compactConditions = removeDuplicateMeaning(conditionLines, criteriaLines);
|
|
724
|
+
if (compactConditions.join("\n") !== conditionLines.join("\n")) {
|
|
725
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "완료 조건", compactConditions.join("\n") || "- 완료 기준을 충족한다.");
|
|
726
|
+
changed = true;
|
|
727
|
+
}
|
|
728
|
+
return { markdown: nextMarkdown, changed };
|
|
729
|
+
}
|
|
730
|
+
function sectionLines(section) {
|
|
731
|
+
return (section ?? "")
|
|
732
|
+
.split("\n")
|
|
733
|
+
.map((line) => line.trim())
|
|
734
|
+
.filter(Boolean);
|
|
735
|
+
}
|
|
736
|
+
function removeDuplicateMeaning(lines, existing) {
|
|
737
|
+
const existingKeys = new Set(existing.map(normalizeMeaningKey).filter(Boolean));
|
|
738
|
+
const seen = new Set();
|
|
739
|
+
const result = [];
|
|
740
|
+
for (const line of lines) {
|
|
741
|
+
const key = normalizeMeaningKey(line);
|
|
742
|
+
if (!key || existingKeys.has(key) || seen.has(key)) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
seen.add(key);
|
|
746
|
+
result.push(line);
|
|
747
|
+
}
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
function normalizeMeaningKey(line) {
|
|
751
|
+
return line
|
|
752
|
+
.replace(/^[-*]\s*/, "")
|
|
753
|
+
.replace(/\s+/g, "")
|
|
754
|
+
.replace(/[./]/g, "")
|
|
755
|
+
.replace(/수정하지않는다|변경하지않는다|건드리지않는다|보호한다/g, "보호")
|
|
756
|
+
.toLowerCase();
|
|
757
|
+
}
|
|
758
|
+
function sanitizeHallucinatedTaskText(markdown) {
|
|
759
|
+
const bannedFragments = [
|
|
760
|
+
"디자이너의 요구",
|
|
761
|
+
"디자이너 요구",
|
|
762
|
+
"PM 요구사항",
|
|
763
|
+
"QA 피드백",
|
|
764
|
+
"사용자 조사 결과",
|
|
765
|
+
"브랜드 정책"
|
|
766
|
+
];
|
|
767
|
+
const genericForbiddenPatterns = [
|
|
768
|
+
/[-*]\s*구조 또는 문서 설정이 변경되면 문서를 업데이트해야 한다\.\s*/g,
|
|
769
|
+
/[-*]\s*문서 업데이트가 필요한 경우.*$/gm
|
|
770
|
+
];
|
|
771
|
+
let next = markdown;
|
|
772
|
+
for (const fragment of bannedFragments) {
|
|
773
|
+
next = next.replaceAll(fragment, "사용자 요청");
|
|
774
|
+
}
|
|
775
|
+
for (const pattern of genericForbiddenPatterns) {
|
|
776
|
+
next = next.replace(pattern, "");
|
|
777
|
+
}
|
|
778
|
+
next = next
|
|
779
|
+
.replace(/페이지에\s*ai가\s*것처럼/gi, "페이지에 AI가 쓴 것처럼 느껴지는")
|
|
780
|
+
.replace(/AI가\s*것처럼/gi, "AI가 쓴 것처럼")
|
|
781
|
+
.replace(/것처럼\s*어색하거나\s*의미가\s*불분명한/gi, "AI가 쓴 것처럼 어색하거나 의미가 불분명한");
|
|
782
|
+
return next.replace(/\n{4,}/g, "\n\n\n");
|
|
783
|
+
}
|
|
784
|
+
function applyCurrentRequestPrioritySections(markdown, requirement) {
|
|
785
|
+
const taskType = classifyTaskType(requirement);
|
|
786
|
+
if (taskType.subtype === "bugfix.navigation_state") {
|
|
787
|
+
let nextMarkdown = markdown;
|
|
788
|
+
let changed = false;
|
|
789
|
+
const target = inferNavigationStateTarget(requirement);
|
|
790
|
+
const goal = `${target}에서 이전 항목으로 돌아갈 때 원래 보던 항목과 상태가 유지되도록 navigation/state 버그를 수정한다.`;
|
|
791
|
+
const problem = `${target}에서 이전으로 돌아가면 원래 있던 항목이 유지되지 않고 다른 항목으로 바뀌는 문제가 있다.`;
|
|
792
|
+
const goalSection = readMarkdownSection(nextMarkdown, "목표") ?? "";
|
|
793
|
+
const problemSection = readMarkdownSection(nextMarkdown, "현재 문제") ?? "";
|
|
794
|
+
if (isWeakOrMismatchedSubject(goalSection, requirement, taskType)) {
|
|
795
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "목표", goal);
|
|
796
|
+
changed = true;
|
|
797
|
+
}
|
|
798
|
+
if (isWeakOrMismatchedSubject(problemSection, requirement, taskType)) {
|
|
799
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", problem);
|
|
800
|
+
changed = true;
|
|
801
|
+
}
|
|
802
|
+
const rulesResult = ensureSectionLines(nextMarkdown, "반드시 지킬 규칙", [
|
|
803
|
+
"- 이전/뒤로 이동 전후의 항목 identity와 선택 상태를 유지한다.",
|
|
804
|
+
"- 새 질문 흐름이나 결과 페이지 문구 수정으로 범위를 바꾸지 않는다.",
|
|
805
|
+
"- 상태 저장/복원, navigation history, 선택 값 갱신 지점을 먼저 확인한다."
|
|
806
|
+
], inferExplicitRouteTargets(requirement));
|
|
807
|
+
nextMarkdown = rulesResult.markdown;
|
|
808
|
+
changed ||= rulesResult.changed;
|
|
809
|
+
return { markdown: nextMarkdown, changed };
|
|
810
|
+
}
|
|
811
|
+
if (!isUiOnlyRefinementRequest(requirement)) {
|
|
812
|
+
return { markdown, changed: false };
|
|
813
|
+
}
|
|
814
|
+
let nextMarkdown = markdown;
|
|
815
|
+
let changed = false;
|
|
816
|
+
const target = inferUiRefinementTarget(requirement);
|
|
817
|
+
const goal = isResultPageRequest(requirement)
|
|
818
|
+
? "결과 페이지의 어색한 표현을 사용자가 바로 이해할 수 있는 말로 다듬는다."
|
|
819
|
+
: `${target} UI를 새 기능처럼 튀지 않게 기존 흐름 안에 자연스럽게 통합한다.`;
|
|
820
|
+
const problem = isResultPageRequest(requirement)
|
|
821
|
+
? "결과 페이지에 AI가 쓴 것처럼 느껴지는 어색하거나 의미가 불분명한 단어가 있다."
|
|
822
|
+
: `${target} 동작 자체는 구현되었지만, UI가 기존 화면 흐름과 어울리지 않아 불필요한 요소가 갑자기 추가된 것처럼 보인다.`;
|
|
823
|
+
const currentGoal = readMarkdownSection(nextMarkdown, "목표")?.trim() ?? "";
|
|
824
|
+
if (!currentGoal || /기능\s*추가|수단\s*추가|추가한다|돌아갈 수 있는 수단|적절하게|AI가 쓴 것처럼 느껴지는 단어/i.test(currentGoal)) {
|
|
825
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "목표", goal);
|
|
826
|
+
changed = true;
|
|
827
|
+
}
|
|
828
|
+
const currentProblem = readMarkdownSection(nextMarkdown, "현재 문제")?.trim() ?? "";
|
|
829
|
+
if (!currentProblem ||
|
|
830
|
+
/기능\s*추가|수단\s*추가|확인 필요|자연스럽게 수정 필요|판단 필요|적절한|올바르게/i.test(currentProblem) ||
|
|
831
|
+
(isResultPageRequest(requirement) && !currentProblem.includes("결과 페이지"))) {
|
|
832
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", problem);
|
|
833
|
+
changed = true;
|
|
834
|
+
}
|
|
835
|
+
const ruleLines = [
|
|
836
|
+
"- 이미 구현된 동작을 다시 추가하는 작업으로 해석하지 않는다.",
|
|
837
|
+
"- 기존 기능은 유지하고, 배치/문구/시각적 위계를 최소 수정으로 자연스럽게 다듬는다.",
|
|
838
|
+
"- 새 버튼/카드/섹션을 무리하게 추가하지 않고 기존 UI 흐름 안에 흡수하는 방향을 우선한다."
|
|
839
|
+
];
|
|
840
|
+
const rulesResult = ensureSectionLines(nextMarkdown, "반드시 지킬 규칙", ruleLines, inferExplicitRouteTargets(requirement));
|
|
841
|
+
nextMarkdown = rulesResult.markdown;
|
|
842
|
+
changed ||= rulesResult.changed;
|
|
843
|
+
return { markdown: nextMarkdown, changed };
|
|
844
|
+
}
|
|
845
|
+
function inferNavigationStateTarget(requirement) {
|
|
846
|
+
if (/설문|survey|질문|문제|question/i.test(requirement)) {
|
|
847
|
+
return "설문 질문 흐름";
|
|
848
|
+
}
|
|
849
|
+
if (/페이지|page/i.test(requirement)) {
|
|
850
|
+
return "페이지 이동 흐름";
|
|
851
|
+
}
|
|
852
|
+
return "이전 이동 흐름";
|
|
853
|
+
}
|
|
854
|
+
function isWeakOrMismatchedSubject(text, requirement, taskType) {
|
|
855
|
+
if (!text.trim() || /확인 필요|판단 필요|적절한|올바르게/i.test(text)) {
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
return detectRequirementMismatch(text, requirement, taskType).length > 0;
|
|
859
|
+
}
|
|
860
|
+
function applyGeneralTaskQualitySections(markdown, context, candidates) {
|
|
861
|
+
let nextMarkdown = markdown;
|
|
862
|
+
let changed = false;
|
|
863
|
+
const explicitRoutes = inferExplicitRouteTargets(context.requirement);
|
|
864
|
+
const uiOnly = isUiOnlyRefinementRequest(context.requirement);
|
|
865
|
+
const protectionLines = inferProtectionLines(context, candidates, uiOnly);
|
|
866
|
+
const forbiddenLines = inferForbiddenLines(protectionLines, uiOnly);
|
|
867
|
+
const completionLines = inferCompletionLines(context.requirement, uiOnly);
|
|
868
|
+
const protectedResult = uiOnly
|
|
869
|
+
? replaceSectionWithLines(nextMarkdown, "보호 대상", protectionLines)
|
|
870
|
+
: ensureSectionLines(nextMarkdown, "보호 대상", protectionLines, explicitRoutes);
|
|
871
|
+
nextMarkdown = protectedResult.markdown;
|
|
872
|
+
changed ||= protectedResult.changed;
|
|
873
|
+
const forbiddenResult = uiOnly
|
|
874
|
+
? replaceSectionWithLines(nextMarkdown, "건드리면 안 되는 것", forbiddenLines)
|
|
875
|
+
: replaceWeakOrGenericSectionLines(nextMarkdown, "건드리면 안 되는 것", forbiddenLines);
|
|
876
|
+
nextMarkdown = forbiddenResult.markdown;
|
|
877
|
+
changed ||= forbiddenResult.changed;
|
|
878
|
+
const completionResult = uiOnly
|
|
879
|
+
? replaceSectionWithLines(nextMarkdown, "완료 조건", completionLines)
|
|
880
|
+
: replaceWeakOrGenericSectionLines(nextMarkdown, "완료 조건", completionLines);
|
|
881
|
+
nextMarkdown = completionResult.markdown;
|
|
882
|
+
changed ||= completionResult.changed;
|
|
883
|
+
const verificationResult = ensureBuildVerification(nextMarkdown);
|
|
884
|
+
nextMarkdown = verificationResult.markdown;
|
|
885
|
+
changed ||= verificationResult.changed;
|
|
886
|
+
const rules = uiOnly
|
|
887
|
+
? [
|
|
888
|
+
"- 기능을 다시 추가하지 않는다.",
|
|
889
|
+
"- 로직 변경 없이 배치, 문구, 시각적 위계 중심으로 최소 수정한다.",
|
|
890
|
+
"- 기존 흐름 안에 흡수할 수 있으면 새 UI 요소를 추가하지 않는다."
|
|
891
|
+
]
|
|
892
|
+
: ["- 사용자 요청에 없는 이해관계자나 배경 설명을 만들지 않는다."];
|
|
893
|
+
const rulesResult = uiOnly
|
|
894
|
+
? replaceSectionWithLines(nextMarkdown, "반드시 지킬 규칙", rules)
|
|
895
|
+
: replaceWeakOrGenericSectionLines(nextMarkdown, "반드시 지킬 규칙", rules);
|
|
896
|
+
nextMarkdown = rulesResult.markdown;
|
|
897
|
+
changed ||= rulesResult.changed;
|
|
898
|
+
const cautionLines = [
|
|
899
|
+
"- 기능/결과 계산 로직은 수정하지 않는다.",
|
|
900
|
+
"- 텍스트와 최소 UI 표현만 수정한다.",
|
|
901
|
+
"- 새 UI 섹션을 추가하지 않는다."
|
|
902
|
+
];
|
|
903
|
+
const cautionResult = uiOnly
|
|
904
|
+
? replaceSectionWithLines(nextMarkdown, "Codex에게 전달할 주의사항", cautionLines)
|
|
905
|
+
: replaceWeakOrGenericSectionLines(nextMarkdown, "Codex에게 전달할 주의사항", cautionLines);
|
|
906
|
+
nextMarkdown = cautionResult.markdown;
|
|
907
|
+
changed ||= cautionResult.changed;
|
|
908
|
+
return { markdown: nextMarkdown, changed };
|
|
909
|
+
}
|
|
910
|
+
function isUiOnlyRefinementRequest(requirement) {
|
|
911
|
+
const normalized = requirement.toLowerCase();
|
|
912
|
+
const hasUi = /ui|화면|버튼|요소|디자인|배치|흐름|시각적|위계|표현/.test(normalized);
|
|
913
|
+
const hasRefinement = /자연스럽|부자연|어색|뜬금|위화감|시각적 위계|배치 조정|표현 수정|더 매끄럽|흐름 안에|녹아들|다듬|개선|정리|튀지 않|불필요/.test(normalized);
|
|
914
|
+
const referencesExistingWork = /잘 이뤄졌|이미|기존|추가된|돌아가|이전/.test(normalized);
|
|
915
|
+
return hasUi && hasRefinement && (referencesExistingWork || !/새로|추가|구현|만들/.test(normalized));
|
|
916
|
+
}
|
|
917
|
+
function inferUiRefinementTarget(requirement) {
|
|
918
|
+
if (isResultPageRequest(requirement)) {
|
|
919
|
+
return "결과 페이지 표현";
|
|
920
|
+
}
|
|
921
|
+
if (/이전|돌아가|back/i.test(requirement)) {
|
|
922
|
+
return "이전으로 돌아가기";
|
|
923
|
+
}
|
|
924
|
+
const tokens = extractRequirementTokens(requirement).slice(0, 3);
|
|
925
|
+
return tokens.length > 0 ? tokens.join(" ") : "요청된";
|
|
926
|
+
}
|
|
927
|
+
function applyTaskTypeSection(markdown, taskType) {
|
|
928
|
+
const body = [
|
|
929
|
+
`- type: ${taskType.type}`,
|
|
930
|
+
...(taskType.subtype ? [`- subtype: ${taskType.subtype}`] : []),
|
|
931
|
+
`- confidence: ${taskType.confidence}`,
|
|
932
|
+
`- strategy: ${taskType.strategy}`,
|
|
933
|
+
`- risk: ${taskType.riskLevel}`,
|
|
934
|
+
...(taskType.domainKeywords?.length ? [`- domain: ${taskType.domainKeywords.join(", ")}`] : [])
|
|
935
|
+
].join("\n");
|
|
936
|
+
const existing = readMarkdownSection(markdown, "작업 유형")?.trim();
|
|
937
|
+
if (existing === body) {
|
|
938
|
+
return { markdown, changed: false };
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
markdown: replaceMarkdownSection(markdown, "작업 유형", body),
|
|
942
|
+
changed: true
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function applyRequirementInterpretationSection(markdown, context) {
|
|
946
|
+
const taskType = context.taskType ?? classifyTaskType(context.requirement);
|
|
947
|
+
const body = [
|
|
948
|
+
`- 원문: ${context.requirement}`,
|
|
949
|
+
`- inferred intent: ${interpretRequirement(context.requirement, taskType)}`,
|
|
950
|
+
`- inferred domain: ${taskType.domainKeywords?.length ? taskType.domainKeywords.join(", ") : "확인 필요"}`,
|
|
951
|
+
`- inferred subtype: ${taskType.subtype ?? taskType.type}`,
|
|
952
|
+
`- inferred risk: ${taskType.riskLevel}`,
|
|
953
|
+
"- 이 작업이 아닌 것:",
|
|
954
|
+
...notThisTaskLines(context.requirement, taskType).map((line) => ` - ${line}`)
|
|
955
|
+
].join("\n");
|
|
956
|
+
const existing = readMarkdownSection(markdown, "사용자 요구사항 해석")?.trim();
|
|
957
|
+
if (existing === body) {
|
|
958
|
+
return { markdown, changed: false };
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
markdown: replaceMarkdownSection(markdown, "사용자 요구사항 해석", body),
|
|
962
|
+
changed: true
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function interpretRequirement(requirement, taskType) {
|
|
966
|
+
if (taskType.type === "product_strategy") {
|
|
967
|
+
return "제품 가치, 사용 이유, 재미/공유/리텐션 요소를 코드 수정 전에 정의하는 discovery-first 작업이다.";
|
|
968
|
+
}
|
|
969
|
+
if (taskType.subtype === "bugfix.navigation_state") {
|
|
970
|
+
return "이전/뒤로 이동 시 원래 보던 항목과 상태가 유지되지 않고 다른 항목으로 바뀌는 navigation/state 버그를 재현하고 수정한다.";
|
|
971
|
+
}
|
|
972
|
+
if (taskType.type === "ui_text_cleanup" || taskType.subtype === "bugfix.text_content") {
|
|
973
|
+
return "사용자가 보는 문구와 표현을 더 명확하고 자연스럽게 다듬는다.";
|
|
974
|
+
}
|
|
975
|
+
if (taskType.type === "i18n") {
|
|
976
|
+
return "기존 언어를 유지하면서 다국어/영어 지원 구조를 단계적으로 추가한다.";
|
|
977
|
+
}
|
|
978
|
+
return "사용자 원문 요구사항 범위 안에서 문제를 해결한다.";
|
|
979
|
+
}
|
|
980
|
+
function notThisTaskLines(requirement, taskType) {
|
|
981
|
+
if (taskType.type === "ui_feature_polish") {
|
|
982
|
+
return [
|
|
983
|
+
"copy-only 문구 수정으로 잠그는 작업",
|
|
984
|
+
"전체 화면 UX 재설계",
|
|
985
|
+
"데이터 모델/API/auth/storage 변경",
|
|
986
|
+
"사용 여부 확인 없는 old component 수정"
|
|
987
|
+
];
|
|
988
|
+
}
|
|
989
|
+
if (taskType.type === "product_strategy") {
|
|
990
|
+
return [
|
|
991
|
+
"바로 UI 섹션이나 기능을 추가하는 구현 작업",
|
|
992
|
+
"결과 페이지 파일을 primary edit target으로 확정하는 작업",
|
|
993
|
+
"결과 계산, 저장, 인증, routing 로직 변경"
|
|
994
|
+
];
|
|
995
|
+
}
|
|
996
|
+
if (taskType.subtype === "bugfix.navigation_state") {
|
|
997
|
+
return [
|
|
998
|
+
"결과 페이지 문구 수정",
|
|
999
|
+
"UI 카피라이팅 정리",
|
|
1000
|
+
"결과 계산 로직 변경",
|
|
1001
|
+
"새 질문 흐름 추가"
|
|
1002
|
+
];
|
|
1003
|
+
}
|
|
1004
|
+
if (taskType.type === "ui_text_cleanup" || taskType.subtype === "bugfix.text_content") {
|
|
1005
|
+
return [
|
|
1006
|
+
"navigation/state 버그 수정",
|
|
1007
|
+
"결과 계산 로직 변경",
|
|
1008
|
+
"데이터 저장/복원 구조 변경"
|
|
1009
|
+
];
|
|
1010
|
+
}
|
|
1011
|
+
if (taskType.type === "i18n") {
|
|
1012
|
+
return [
|
|
1013
|
+
"전체 페이지 한국어 문구를 한 번에 영어로 덮어쓰기",
|
|
1014
|
+
"인증/데이터 저장/분석 로직 수정",
|
|
1015
|
+
"모든 화면 번역 완료로 간주하기"
|
|
1016
|
+
];
|
|
1017
|
+
}
|
|
1018
|
+
return ["사용자 입력에 없는 이전 task 목표 재사용", "관련 없는 파일/도메인 수정"];
|
|
1019
|
+
}
|
|
1020
|
+
function applyCompletionCriteriaSection(markdown, criteria) {
|
|
1021
|
+
const lines = [
|
|
1022
|
+
...criteria.requiredChecks,
|
|
1023
|
+
...(criteria.blockingFailures ?? []).map((item) => `실패 조건: ${item}`)
|
|
1024
|
+
].map((item) => `- ${item}`);
|
|
1025
|
+
const body = lines.join("\n");
|
|
1026
|
+
const existing = readMarkdownSection(markdown, "완료 기준")?.trim();
|
|
1027
|
+
if (existing === body) {
|
|
1028
|
+
return { markdown, changed: false };
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
markdown: replaceMarkdownSection(markdown, "완료 기준", body),
|
|
1032
|
+
changed: true
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function inferExperimentIdeas(subtype, requirement) {
|
|
1036
|
+
if (subtype === "viral_loop") {
|
|
1037
|
+
return [
|
|
1038
|
+
"- 결과 공유 시 '내 점수 vs 친구 점수' 비교 포맷 노출",
|
|
1039
|
+
"- 결과 일부를 blur 처리하고 '공유하면 전체 공개' 유도",
|
|
1040
|
+
"- 공유 버튼 문구를 '결과 공유' 대신 감정 반응 유발 문장으로 교체",
|
|
1041
|
+
"- 결과 카드에 '상위 X%' 수치 추가로 자랑 동기 강화",
|
|
1042
|
+
"- '친구가 본 당신' 형식으로 타인 시선 비교 요소 추가"
|
|
1043
|
+
];
|
|
1044
|
+
}
|
|
1045
|
+
if (subtype === "retention_hook") {
|
|
1046
|
+
return [
|
|
1047
|
+
"- 진행 중 '상위 3%만 여기까지 왔습니다' 희소성 카피 실험",
|
|
1048
|
+
"- 결과 화면에 '답을 바꾸면 결과가 달라질 수 있어요' 재시도 훅",
|
|
1049
|
+
"- 마지막 문항 진입 시 '거의 다 왔어요' 기대감 카피 강화",
|
|
1050
|
+
"- 결과 저장 + 일정 기간 후 '결과가 바뀌었을까?' 재방문 유도",
|
|
1051
|
+
"- 테스트 완료 직후 '한 번 더 해볼까요?' 재시작 진입점 강화"
|
|
1052
|
+
];
|
|
1053
|
+
}
|
|
1054
|
+
if (subtype === "fun_factor") {
|
|
1055
|
+
return [
|
|
1056
|
+
"- 결과 제목을 현재 유행 밈 문법으로 재구성",
|
|
1057
|
+
"- 문항 표현에 공감/유머 추가 (딱딱한 질문 → 대화체)",
|
|
1058
|
+
"- 결과 화면에 과장된 감탄사/이모지로 감정 반응 강화",
|
|
1059
|
+
"- TikTok/Threads 스타일 짧고 강한 문장 구조 실험",
|
|
1060
|
+
"- '당신은 X형 인간' 같은 정체성 라벨링으로 공감 자극"
|
|
1061
|
+
];
|
|
1062
|
+
}
|
|
1063
|
+
if (subtype === "result_shareability") {
|
|
1064
|
+
return [
|
|
1065
|
+
"- 결과 제목을 더 공격적/밈 스타일로 교체",
|
|
1066
|
+
"- 결과 카드를 스크린샷하기 좋은 짧은 문장 구조로 재구성",
|
|
1067
|
+
"- '친구가 본 당신' 형식의 비교 요소 추가",
|
|
1068
|
+
"- 결과 일부를 blur 처리하고 공유 후 전체 공개 유도",
|
|
1069
|
+
"- 결과 공유 시 친구와 점수 비교 유도"
|
|
1070
|
+
];
|
|
1071
|
+
}
|
|
1072
|
+
// engagement_positioning default — also covers concept_validation, product_copy
|
|
1073
|
+
return [
|
|
1074
|
+
"- 서비스 진입 카피를 '왜 해야 하는지' 동기 유발 한 문장으로 교체",
|
|
1075
|
+
"- 결과 제목을 더 공격적/밈 스타일로 변경",
|
|
1076
|
+
"- 결과 공유 시 친구 비교 유도",
|
|
1077
|
+
"- 테스트 진행 중 기대감 카피 강화",
|
|
1078
|
+
"- TikTok/Threads 스타일 짧은 문장 구조 실험"
|
|
1079
|
+
];
|
|
1080
|
+
}
|
|
1081
|
+
function applyUiFeaturePolishSections(markdown, context, candidates) {
|
|
1082
|
+
const references = candidates.slice(0, 8).map((candidate) => `- ${candidate} (후보)`);
|
|
1083
|
+
const memoRequest = /(메모|memo|생각들|thought)/i.test(context.requirement);
|
|
1084
|
+
let nextMarkdown = markdown;
|
|
1085
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "목표", memoRequest
|
|
1086
|
+
? "어색한 표현을 제거하고, 카드의 메모 개수 표시에서 해당 메모 전체를 볼 수 있는 scoped UI 기능을 추가한다."
|
|
1087
|
+
: "요청된 문구 정리와 작은 UI interaction을 실제 렌더 경로 안에서 최소 범위로 반영한다.");
|
|
1088
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", memoRequest
|
|
1089
|
+
? "특정 표현이 어색하게 노출되고 있고, 카드의 개수 표시가 어떤 메모를 의미하는지 전체 목록으로 확인할 수 없다."
|
|
1090
|
+
: "문구 정리와 함께 사용자가 직접 확인하거나 열어볼 수 있는 작은 UI interaction이 필요하다.");
|
|
1091
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 범위", [
|
|
1092
|
+
"- 현재 실제 화면 렌더 경로를 먼저 확인한다.",
|
|
1093
|
+
"- 사용 중인 카드/목록/피드 컴포넌트를 기준으로 최소 수정한다.",
|
|
1094
|
+
"- 필요 시 modal, right sheet, bottom sheet, inline expand 중 현재 UI에 맞는 방식으로 scoped view를 추가한다."
|
|
1095
|
+
].join("\n"));
|
|
1096
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 대상", references.length > 0 ? references.join("\n") : "- 실제 렌더 경로 확인 후 사용 중인 UI 컴포넌트만 후보로 확정한다.");
|
|
1097
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "참고 대상", "- 렌더 경로 확인에 필요한 주변 page/shell/store 파일만 참고한다.");
|
|
1098
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "보호 대상", [
|
|
1099
|
+
"- 데이터 모델",
|
|
1100
|
+
"- store/localStorage 저장 및 복원 로직",
|
|
1101
|
+
"- API/auth/OpenAI/Supabase 연동",
|
|
1102
|
+
"- 기존 입력/저장/표시 흐름",
|
|
1103
|
+
"- unrelated UI"
|
|
1104
|
+
].join("\n"));
|
|
1105
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "반드시 지킬 규칙", [
|
|
1106
|
+
"- 실제 렌더 경로를 확인하기 전까지 특정 파일을 primary target으로 확정하지 않는다.",
|
|
1107
|
+
"- 사용되지 않는 old component를 수정하지 않는다.",
|
|
1108
|
+
"- copy-only 작업으로 처리하지 않는다.",
|
|
1109
|
+
"- scoped UI state는 허용한다.",
|
|
1110
|
+
"- 데이터 저장 구조/API/auth/OpenAI/Supabase 연동은 변경하지 않는다.",
|
|
1111
|
+
"- 전체 화면 UX를 재설계하지 않는다."
|
|
1112
|
+
].join("\n"));
|
|
1113
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "허용되는 변경", [
|
|
1114
|
+
"- 요청된 표현 제거/문구 정리",
|
|
1115
|
+
"- 카드의 개수/더보기 UI 클릭 이벤트",
|
|
1116
|
+
"- 선택된 item/topic/group에 대한 local component state",
|
|
1117
|
+
"- full list modal/right sheet/bottom sheet/inline expand",
|
|
1118
|
+
"- 목록 scroll 처리",
|
|
1119
|
+
"- PC/mobile 대응"
|
|
1120
|
+
].join("\n"));
|
|
1121
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "건드리면 안 되는 것", [
|
|
1122
|
+
"- 전체 화면 재설계",
|
|
1123
|
+
"- 데이터 구조 대규모 변경",
|
|
1124
|
+
"- 기존 store/localStorage 파괴",
|
|
1125
|
+
"- unrelated file 수정",
|
|
1126
|
+
"- 실제 사용 여부 확인 없는 old file 수정",
|
|
1127
|
+
"- 인증/결제/분석/서버 로직 수정"
|
|
1128
|
+
].join("\n"));
|
|
1129
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "완료 조건", [
|
|
1130
|
+
"- 요청된 어색한 표현이 사용자 화면에서 제거된다.",
|
|
1131
|
+
"- 카드의 개수 또는 더보기 UI를 통해 해당 항목 전체를 확인할 수 있다.",
|
|
1132
|
+
"- PC와 모바일에서 목록 확인이 가능하다.",
|
|
1133
|
+
"- 기존 입력/저장/표시 흐름이 유지된다.",
|
|
1134
|
+
"- `pnpm run build`가 성공한다."
|
|
1135
|
+
].join("\n"));
|
|
1136
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "검증 명령어", "- `pnpm run build`");
|
|
1137
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "Codex에게 전달할 주의사항", [
|
|
1138
|
+
"- 먼저 실제 렌더 경로와 사용 중인 컴포넌트를 확인한다.",
|
|
1139
|
+
"- copy cleanup과 scoped interaction을 같은 작업 안에서 최소 범위로 처리한다.",
|
|
1140
|
+
"- local UI state는 허용하지만 데이터 저장/API/auth/server 연동은 수정하지 않는다."
|
|
1141
|
+
].join("\n"));
|
|
1142
|
+
return { markdown: nextMarkdown, changed: true };
|
|
1143
|
+
}
|
|
1144
|
+
function applyProductStrategySections(markdown, context, candidates) {
|
|
1145
|
+
const references = candidates.slice(0, 6).map((candidate) => `- ${candidate}`);
|
|
1146
|
+
let nextMarkdown = markdown;
|
|
1147
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "목표", "서비스를 꼭 써야 하는 이유와 재미/공유/재방문 동기를 코드 수정 전에 정의한다.");
|
|
1148
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", "서비스의 핵심 사용 이유, 재미 요소, 유행성/공유성이 아직 명확하지 않아 바로 기능 추가로 해결할 수 있는 상태가 아니다.");
|
|
1149
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 범위", "- 코드 수정 전 기획 정의 필요\n- 직접 수정 대상 없음");
|
|
1150
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 대상", "- 없음. product brief 승인 전까지 코드 수정하지 않는다.");
|
|
1151
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "참고 대상", references.length > 0 ? references.join("\n") : "- 현재 결과/공유/진입 흐름 관련 파일은 필요 시 reference로만 확인한다.");
|
|
1152
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "보호 대상", [
|
|
1153
|
+
"- 결과 계산 로직",
|
|
1154
|
+
"- 상태 저장/복원 로직",
|
|
1155
|
+
"- routing/auth/fetch 처리",
|
|
1156
|
+
"- 검증되지 않은 데이터 구조",
|
|
1157
|
+
"- 기존 공유/결과 렌더링 동작"
|
|
1158
|
+
].join("\n"));
|
|
1159
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "구현 전 정의해야 할 것", [
|
|
1160
|
+
"- 사용자가 이 서비스를 굳이 해야 하는 한 문장 이유",
|
|
1161
|
+
"- 결과를 친구에게 공유하고 싶은 이유",
|
|
1162
|
+
"- 끝까지 진행하게 만드는 긴장감/기대감",
|
|
1163
|
+
"- 결과 페이지에서 기억에 남는 표현 방식",
|
|
1164
|
+
"- 현재 유행 문법과 연결할 수 있는 요소",
|
|
1165
|
+
"- 코드로 구현할 최소 범위와 검증 기준"
|
|
1166
|
+
].join("\n"));
|
|
1167
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "추천 실험 방향", inferExperimentIdeas(context.taskType?.subtype, context.requirement).join("\n"));
|
|
1168
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "반드시 지킬 규칙", [
|
|
1169
|
+
"- 바로 코드 수정하지 말고 product brief 또는 implementation proposal을 먼저 작성한다.",
|
|
1170
|
+
"- 관련 파일은 reference로만 보고 수정 대상으로 확정하지 않는다.",
|
|
1171
|
+
"- 구현이 필요하면 최소 변경 범위와 검증 기준을 먼저 제안한다."
|
|
1172
|
+
].join("\n"));
|
|
1173
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "건드리면 안 되는 것", [
|
|
1174
|
+
"- 바로 UI 섹션 추가",
|
|
1175
|
+
"- 결과 계산 로직",
|
|
1176
|
+
"- 상태 저장/복원",
|
|
1177
|
+
"- routing/auth/fetch",
|
|
1178
|
+
"- 검증되지 않은 데이터 구조 변경"
|
|
1179
|
+
].join("\n"));
|
|
1180
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "완료 조건", [
|
|
1181
|
+
"- 서비스의 핵심 hook이 한 문장으로 정의된다.",
|
|
1182
|
+
"- 공유하고 싶은 이유와 다시 써볼 이유가 분리되어 적힌다.",
|
|
1183
|
+
"- 구현 후보가 최소 범위와 검증 기준으로 정리된다.",
|
|
1184
|
+
"- 코드 수정이 필요하면 승인받을 수 있는 implementation proposal이 있다."
|
|
1185
|
+
].join("\n"));
|
|
1186
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "검증 명령어", "- 코드 수정 전에는 실행 명령 없음\n- 코드 수정 범위가 승인되면 `pnpm run build`");
|
|
1187
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "Codex에게 전달할 주의사항", [
|
|
1188
|
+
"- 바로 코드 수정하지 말고 먼저 product brief 또는 implementation proposal을 작성한다.",
|
|
1189
|
+
"- 코드 수정이 필요하면 최소 변경 범위를 제안한 뒤 진행한다.",
|
|
1190
|
+
"- 결과/공유 관련 파일은 reference일 뿐 primary target이 아니다."
|
|
1191
|
+
].join("\n"));
|
|
1192
|
+
return { markdown: nextMarkdown, changed: true };
|
|
1193
|
+
}
|
|
1194
|
+
function applyI18nTaskDecomposition(markdown, context, taskType) {
|
|
1195
|
+
const plan = inferI18nPlan(context.projectFiles ?? []);
|
|
1196
|
+
let nextMarkdown = markdown;
|
|
1197
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "목표", "기존 한국어 UI를 유지한 채 영어 리소스와 언어 전환 기반을 추가하는 1단계 i18n 구조를 도입한다.");
|
|
1198
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", "전체 영문 변환은 단순 문자열 치환이 아니라 번역 리소스와 언어 선택 구조가 필요한 대규모 작업이다. 한 번에 모든 페이지의 한국어를 영어로 덮어쓰면 기존 UI와 기능을 깨뜨릴 수 있다.");
|
|
1199
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 범위", plan.editTargets.join("\n"));
|
|
1200
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 대상", plan.editTargets.join("\n"));
|
|
1201
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "참고 대상", plan.referenceTargets.join("\n"));
|
|
1202
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "보호 대상", i18nProtectionLines().join("\n"));
|
|
1203
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "완료 기준", [
|
|
1204
|
+
"- 사용자 노출 문자열 inventory가 먼저 정리된다.",
|
|
1205
|
+
"- 대상 페이지에서 locale=en 시 한국어 UI가 섞이지 않는다.",
|
|
1206
|
+
"- 사용자 노출 문자열은 locale resource로 이동한다.",
|
|
1207
|
+
"- aria-label/title/placeholder/metadata 문자열도 locale resource 기준으로 처리한다.",
|
|
1208
|
+
"- locale resource key가 ko/en 사이에서 대응된다.",
|
|
1209
|
+
"- 실패 조건: 고유명사 외 하드코딩 사용자 노출 문자열 잔존",
|
|
1210
|
+
"- 실패 조건: locale=en 화면에 한국어 문구 혼입",
|
|
1211
|
+
"- 실패 조건: ko/en resource key 불일치",
|
|
1212
|
+
"- 실패 조건: metadata/aria-label/title/placeholder 미처리"
|
|
1213
|
+
].join("\n"));
|
|
1214
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "1단계 목표", [
|
|
1215
|
+
"- 프로젝트 전체 번역을 바로 끝내려 하지 않는다.",
|
|
1216
|
+
"- 번역 리소스 구조와 언어 전환 기반을 먼저 만든다.",
|
|
1217
|
+
"- 대표 public 화면 1개에서만 ko/en 리소스 사용을 샘플로 연결한다."
|
|
1218
|
+
].join("\n"));
|
|
1219
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이번 단계", [
|
|
1220
|
+
"- i18n 리소스와 설정 구조를 추가한다.",
|
|
1221
|
+
"- 대표 화면 1개에서만 언어 리소스 사용을 연결한다.",
|
|
1222
|
+
"- 전체 화면 번역은 다음 단계로 남긴다."
|
|
1223
|
+
].join("\n"));
|
|
1224
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이번 작업 범위", [
|
|
1225
|
+
"- 기존 한국어 원본 유지",
|
|
1226
|
+
"- 영어 리소스 추가",
|
|
1227
|
+
"- messages/ko, messages/en 또는 lib/i18n 기반 구조 추가",
|
|
1228
|
+
"- 언어 선택/조회 helper 또는 provider 최소 구조 추가",
|
|
1229
|
+
"- 대표 public/landing/about 화면 1개에만 샘플 적용"
|
|
1230
|
+
].join("\n"));
|
|
1231
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이후 단계", [
|
|
1232
|
+
"- dashboard 화면 번역 적용",
|
|
1233
|
+
"- record flow 번역 적용",
|
|
1234
|
+
"- settings/archive/admin 화면 번역 적용",
|
|
1235
|
+
"- 알림, 에러 메시지, 빈 상태 문구 번역 확장",
|
|
1236
|
+
"- 필요 시 locale routing 또는 middleware 도입 검토"
|
|
1237
|
+
].join("\n"));
|
|
1238
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이번 작업에서 제외할 것", [
|
|
1239
|
+
"- 전체 페이지 텍스트를 한 번에 직접 수정",
|
|
1240
|
+
"- 한국어 문구를 영어로 덮어쓰기",
|
|
1241
|
+
"- admin/dashboard/settings/checklist 등 전역 화면 무차별 수정",
|
|
1242
|
+
"- locale routing/middleware 변경. 명시적으로 필요할 때만 별도 단계에서 검토",
|
|
1243
|
+
"- auth/session, Supabase, 저장/분석 로직 수정"
|
|
1244
|
+
].join("\n"));
|
|
1245
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "반드시 지킬 규칙", [
|
|
1246
|
+
"- i18n은 1단계 구조 도입 작업으로 분해한다.",
|
|
1247
|
+
"- 기존 한국어 UI와 문구는 기본값으로 유지한다.",
|
|
1248
|
+
"- 영어는 별도 리소스로 추가한다.",
|
|
1249
|
+
"- 대표 화면 1개만 샘플 적용하고 전체 번역은 이후 단계로 남긴다.",
|
|
1250
|
+
"- 기능 로직, 데이터 로직, 인증 로직은 변경하지 않는다."
|
|
1251
|
+
].join("\n"));
|
|
1252
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "건드리면 안 되는 것", [
|
|
1253
|
+
"- 기존 한국어 copy 삭제 또는 영어 덮어쓰기",
|
|
1254
|
+
"- auth/session 처리",
|
|
1255
|
+
"- Supabase query/mutation",
|
|
1256
|
+
"- record 저장/분석 로직",
|
|
1257
|
+
"- Edge Functions",
|
|
1258
|
+
"- notification logic",
|
|
1259
|
+
"- locale routing이 이번 범위로 확정되지 않은 상태의 routing behavior"
|
|
1260
|
+
].join("\n"));
|
|
1261
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "완료 조건", [
|
|
1262
|
+
"- 한국어 기존 UI가 유지된다.",
|
|
1263
|
+
"- 영어 리소스 구조가 추가된다.",
|
|
1264
|
+
"- 샘플 화면에서 언어 리소스를 사용할 수 있다.",
|
|
1265
|
+
"- 전체 번역은 다음 단계로 남긴다.",
|
|
1266
|
+
"- `pnpm run build`가 성공한다."
|
|
1267
|
+
].join("\n"));
|
|
1268
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "검증 명령어", "- `pnpm run build`");
|
|
1269
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "Codex에게 전달할 주의사항", [
|
|
1270
|
+
"- 전체 페이지를 한 번에 번역하지 말고 i18n 구조와 샘플 적용만 구현한다.",
|
|
1271
|
+
"- 한국어 원본은 유지하고 영어 메시지를 별도 파일/구조로 추가한다.",
|
|
1272
|
+
"- 페이지별 텍스트 교체가 필요하면 대표 public 화면 1개로 제한한다.",
|
|
1273
|
+
"- 데이터, 인증, 저장, 분석, 알림 로직은 수정하지 않는다."
|
|
1274
|
+
].join("\n"));
|
|
1275
|
+
return { markdown: nextMarkdown, changed: true };
|
|
1276
|
+
}
|
|
1277
|
+
function applyGenericPhasingSections(markdown, taskType) {
|
|
1278
|
+
let nextMarkdown = markdown;
|
|
1279
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이번 단계", [
|
|
1280
|
+
`- ${taskType.type} 작업을 한 번에 전체 적용하지 않고 1단계 범위로 제한한다.`,
|
|
1281
|
+
"- 영향 범위와 위험을 확인할 수 있는 최소 구조 또는 대표 경로만 다룬다.",
|
|
1282
|
+
"- 다음 단계로 넘길 파일/화면을 명시한다."
|
|
1283
|
+
].join("\n"));
|
|
1284
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이후 단계", [
|
|
1285
|
+
"- 남은 화면/모듈로 점진 확장한다.",
|
|
1286
|
+
"- 호환성, 회귀 위험, 검증 결과를 확인한 뒤 다음 범위를 연다.",
|
|
1287
|
+
"- 필요하면 rollback point를 기준으로 단계별 반영한다."
|
|
1288
|
+
].join("\n"));
|
|
1289
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "이번 작업에서 제외할 것", [
|
|
1290
|
+
"- 전체 프로젝트 일괄 수정",
|
|
1291
|
+
"- 요청과 직접 관련 없는 전역 파일 수정",
|
|
1292
|
+
"- 검증 없이 구조를 대체하는 변경",
|
|
1293
|
+
"- 기존 정상 동작을 바꾸는 리팩터링"
|
|
1294
|
+
].join("\n"));
|
|
1295
|
+
return { markdown: nextMarkdown, changed: true };
|
|
1296
|
+
}
|
|
1297
|
+
function inferI18nPlan(projectFiles) {
|
|
1298
|
+
const existingI18nFiles = projectFiles
|
|
1299
|
+
.filter((file) => /(^|\/)(i18n|locale|locales|messages|translations|dictionaries)(\/|\.|-|_)/i.test(file))
|
|
1300
|
+
.slice(0, 5)
|
|
1301
|
+
.map((file) => `- ${file}`);
|
|
1302
|
+
const editTargets = existingI18nFiles.length > 0 ? existingI18nFiles : [
|
|
1303
|
+
"- lib/i18n/config.ts (신규 후보)",
|
|
1304
|
+
"- messages/ko.json (신규 후보)",
|
|
1305
|
+
"- messages/en.json (신규 후보)"
|
|
1306
|
+
];
|
|
1307
|
+
const layoutFile = firstExisting(projectFiles, ["app/layout.tsx", "src/app/layout.tsx", "pages/_app.tsx", "src/pages/_app.tsx"]);
|
|
1308
|
+
if (layoutFile) {
|
|
1309
|
+
editTargets.push(`- ${layoutFile} (provider 필요 시 최소 수정)`);
|
|
1310
|
+
}
|
|
1311
|
+
const representativePage = findRepresentativePublicPage(projectFiles);
|
|
1312
|
+
if (representativePage) {
|
|
1313
|
+
editTargets.push(`- ${representativePage} (샘플 적용 후보)`);
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
editTargets.push("- 대표 public page 1개 (확인 필요, 샘플 적용 후보)");
|
|
1317
|
+
}
|
|
1318
|
+
const referenceTargets = [
|
|
1319
|
+
representativePage ? `- ${representativePage} (대표 화면 구조 참고)` : "- 대표 public/landing/about 화면 1개 확인 필요",
|
|
1320
|
+
"- 기존 한국어 문구와 화면 흐름",
|
|
1321
|
+
"- package.json (i18n 라이브러리 도입 여부 확인)"
|
|
1322
|
+
];
|
|
1323
|
+
return {
|
|
1324
|
+
editTargets: [...new Set(editTargets)].slice(0, 8),
|
|
1325
|
+
referenceTargets
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function firstExisting(projectFiles, candidates) {
|
|
1329
|
+
const fileSet = new Set(projectFiles);
|
|
1330
|
+
return candidates.find((candidate) => fileSet.has(candidate));
|
|
1331
|
+
}
|
|
1332
|
+
function findRepresentativePublicPage(projectFiles) {
|
|
1333
|
+
const preferred = [
|
|
1334
|
+
"app/about/page.tsx",
|
|
1335
|
+
"src/app/about/page.tsx",
|
|
1336
|
+
"app/about/service/page.tsx",
|
|
1337
|
+
"src/app/about/service/page.tsx",
|
|
1338
|
+
"app/page.tsx",
|
|
1339
|
+
"src/app/page.tsx",
|
|
1340
|
+
"pages/index.tsx",
|
|
1341
|
+
"src/pages/index.tsx"
|
|
1342
|
+
];
|
|
1343
|
+
const direct = firstExisting(projectFiles, preferred);
|
|
1344
|
+
if (direct) {
|
|
1345
|
+
return direct;
|
|
1346
|
+
}
|
|
1347
|
+
return projectFiles.find((file) => /(^|\/)(about|landing|public|home)(\/|[-_.])/.test(file) && /\.(tsx|jsx|ts|js)$/.test(file));
|
|
1348
|
+
}
|
|
1349
|
+
function i18nProtectionLines() {
|
|
1350
|
+
return [
|
|
1351
|
+
"- auth/session 처리",
|
|
1352
|
+
"- Supabase query/mutation",
|
|
1353
|
+
"- record 저장/분석 로직",
|
|
1354
|
+
"- Edge Functions",
|
|
1355
|
+
"- notification logic",
|
|
1356
|
+
"- existing Korean copy",
|
|
1357
|
+
"- routing behavior. locale routing이 명시 범위일 때만 별도 검토"
|
|
1358
|
+
];
|
|
1359
|
+
}
|
|
1360
|
+
function inferProtectionLines(context, candidates, uiOnly) {
|
|
1361
|
+
const text = [context.requirement, ...(context.codeContexts ?? []).map((item) => `${item.path}\n${item.excerpt}`), ...candidates].join("\n").toLowerCase();
|
|
1362
|
+
const lines = new Set();
|
|
1363
|
+
if (uiOnly) {
|
|
1364
|
+
lines.add("- 결과 계산/타입 산출 로직");
|
|
1365
|
+
lines.add("- 상태 저장/복원 로직");
|
|
1366
|
+
lines.add("- routing/provider 설정");
|
|
1367
|
+
lines.add("- fetch/auth/session 처리");
|
|
1368
|
+
}
|
|
1369
|
+
const rules = [
|
|
1370
|
+
[/localstorage|sessionstorage|저장|복원|restore|state|useState|zustand|reducer|상태/i, "- 상태 저장/복원 로직"],
|
|
1371
|
+
[/auth|session|login|로그인|인증|fetch|api|cache|캐시/i, "- fetch/auth/session 처리"],
|
|
1372
|
+
[/calculate|calculation|score|결과\s*계산|계산|type|타입/i, "- 결과 계산/타입 산출 로직"],
|
|
1373
|
+
[/route|router|navigation|routing|경로|라우팅|provider|context/i, "- routing/provider 설정"],
|
|
1374
|
+
[/animation|motion|transition|애니메이션/i, "- animation state와 transition 동작"],
|
|
1375
|
+
[/layout|shell|wrapper|레이아웃/i, "- 기존 layout/shell/wrapper 구조"]
|
|
1376
|
+
];
|
|
1377
|
+
for (const [pattern, line] of rules) {
|
|
1378
|
+
if (pattern.test(text)) {
|
|
1379
|
+
lines.add(line);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (lines.size === 0) {
|
|
1383
|
+
lines.add("- 사용자 요청과 직접 관련 없는 기존 동작");
|
|
1384
|
+
lines.add("- 기존 데이터 처리와 상태 관리 로직");
|
|
1385
|
+
}
|
|
1386
|
+
return compactProtectionLines([...lines]).slice(0, 8);
|
|
1387
|
+
}
|
|
1388
|
+
function compactProtectionLines(lines) {
|
|
1389
|
+
const canonical = new Map();
|
|
1390
|
+
const mappings = [
|
|
1391
|
+
[/결과|계산|타입|score|calculation/i, "- 결과 계산/타입 산출 로직"],
|
|
1392
|
+
[/localStorage|sessionStorage|저장|복원|state|상태/i, "- 상태 저장/복원 로직"],
|
|
1393
|
+
[/fetch|auth|session|login|api|인증|로그인/i, "- fetch/auth/session 처리"],
|
|
1394
|
+
[/routing|route|router|navigation|provider|context|라우팅/i, "- routing/provider 설정"],
|
|
1395
|
+
[/animation|transition|motion|애니메이션/i, "- animation/transition 상태"],
|
|
1396
|
+
[/cache|query|캐시/i, "- cache/query 상태 관리"],
|
|
1397
|
+
[/layout|shell|wrapper|레이아웃/i, "- layout/shell/wrapper 구조"]
|
|
1398
|
+
];
|
|
1399
|
+
for (const line of lines) {
|
|
1400
|
+
const normalized = line.replace(/^-\s*/, "").trim();
|
|
1401
|
+
if (looksLikeFilePath(normalized)) {
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
const mapped = mappings.find(([pattern]) => pattern.test(normalized))?.[1] ?? `- ${normalized}`;
|
|
1405
|
+
canonical.set(mapped, mapped);
|
|
1406
|
+
}
|
|
1407
|
+
return [...canonical.values()];
|
|
1408
|
+
}
|
|
1409
|
+
function inferForbiddenLines(protectionLines, uiOnly) {
|
|
1410
|
+
const base = protectionLines.map((line) => line.replace(/^-\s*/, "- 수정 금지: "));
|
|
1411
|
+
if (uiOnly) {
|
|
1412
|
+
base.unshift("- 기능 추가로 해석하지 않고 텍스트와 최소 UI 표현만 수정한다.");
|
|
1413
|
+
}
|
|
1414
|
+
return [...new Set(base)].slice(0, 8);
|
|
1415
|
+
}
|
|
1416
|
+
function inferCompletionLines(requirement, uiOnly) {
|
|
1417
|
+
if (!uiOnly) {
|
|
1418
|
+
return ["- 사용자 요청의 관찰 가능한 결과가 동작한다.", "- 기존 핵심 동작이 유지된다."];
|
|
1419
|
+
}
|
|
1420
|
+
const target = inferUiRefinementTarget(requirement);
|
|
1421
|
+
if (isResultPageRequest(requirement)) {
|
|
1422
|
+
return [
|
|
1423
|
+
"- 결과 페이지 문구가 사용자가 바로 이해할 수 있는 말로 정리된다.",
|
|
1424
|
+
"- AI가 쓴 것처럼 어색하거나 의미가 불분명한 단어가 줄어든다.",
|
|
1425
|
+
"- 기능, 결과 계산, 상태 저장/복원 로직은 유지된다.",
|
|
1426
|
+
"- 텍스트와 필요한 최소 UI 표현만 수정된다."
|
|
1427
|
+
];
|
|
1428
|
+
}
|
|
1429
|
+
return [
|
|
1430
|
+
`- ${target} UI가 기존 화면 흐름 안에 자연스럽게 녹아든다.`,
|
|
1431
|
+
"- 기존 기능 동작은 유지된다.",
|
|
1432
|
+
"- 새로 덧붙인 UI 요소처럼 튀어 보이지 않는다.",
|
|
1433
|
+
"- 불필요한 카드, 버튼, 설명 문구를 추가하지 않는다."
|
|
1434
|
+
];
|
|
1435
|
+
}
|
|
1436
|
+
function replaceWeakOrGenericSectionLines(markdown, section, lines) {
|
|
1437
|
+
const existingBody = readMarkdownSection(markdown, section);
|
|
1438
|
+
const uniqueLines = [...new Set(lines.filter(Boolean))];
|
|
1439
|
+
if (existingBody === undefined) {
|
|
1440
|
+
return {
|
|
1441
|
+
markdown: `${markdown.trimEnd()}\n\n## ${section}\n${uniqueLines.join("\n")}\n`,
|
|
1442
|
+
changed: true
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
const cleanBody = existingBody
|
|
1446
|
+
.split("\n")
|
|
1447
|
+
.filter((line) => !/문서 업데이트|문서 설정|정책|판단 필요|적절한|올바르게|확인할 수 있어야 한다|자연스럽게 수정 필요/i.test(line))
|
|
1448
|
+
.join("\n")
|
|
1449
|
+
.trim();
|
|
1450
|
+
const shouldReplace = isWeakScopeBody(cleanBody) || cleanBody.length === 0;
|
|
1451
|
+
if (shouldReplace) {
|
|
1452
|
+
return {
|
|
1453
|
+
markdown: replaceMarkdownSection(markdown, section, uniqueLines.join("\n")),
|
|
1454
|
+
changed: true
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
const missingLines = uniqueLines.filter((line) => !cleanBody.includes(line.replace(/^-\s+/, "")));
|
|
1458
|
+
if (missingLines.length === 0 && cleanBody === existingBody.trim()) {
|
|
1459
|
+
return { markdown, changed: false };
|
|
1460
|
+
}
|
|
1461
|
+
return {
|
|
1462
|
+
markdown: replaceMarkdownSection(markdown, section, [cleanBody, ...missingLines].filter(Boolean).join("\n")),
|
|
1463
|
+
changed: true
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
function replaceSectionWithLines(markdown, section, lines) {
|
|
1467
|
+
const body = [...new Set(lines.filter(Boolean))].join("\n");
|
|
1468
|
+
const existingBody = readMarkdownSection(markdown, section)?.trim();
|
|
1469
|
+
if (existingBody === body) {
|
|
1470
|
+
return { markdown, changed: false };
|
|
1471
|
+
}
|
|
1472
|
+
return {
|
|
1473
|
+
markdown: replaceMarkdownSection(markdown, section, body),
|
|
1474
|
+
changed: true
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
function normalizeTaskTargetProtectionSeparation(markdown) {
|
|
1478
|
+
const editTargets = extractSectionPathSet(markdown, "수정 대상");
|
|
1479
|
+
const scopeTargets = extractSectionPathSet(markdown, "수정 범위");
|
|
1480
|
+
const targets = new Set([...editTargets, ...scopeTargets]);
|
|
1481
|
+
if (targets.size === 0) {
|
|
1482
|
+
return { markdown, changed: false };
|
|
1483
|
+
}
|
|
1484
|
+
const protectedBody = readMarkdownSection(markdown, "보호 대상");
|
|
1485
|
+
if (protectedBody === undefined) {
|
|
1486
|
+
return { markdown, changed: false };
|
|
1487
|
+
}
|
|
1488
|
+
const filtered = compactProtectionLines(protectedBody
|
|
1489
|
+
.split("\n")
|
|
1490
|
+
.map((line) => line.trim())
|
|
1491
|
+
.filter(Boolean)
|
|
1492
|
+
.filter((line) => {
|
|
1493
|
+
const path = extractPathFromLine(line);
|
|
1494
|
+
return !path || !targets.has(path);
|
|
1495
|
+
}));
|
|
1496
|
+
const nextBody = filtered.length > 0 ? filtered.join("\n") : "- 사용자 요청과 직접 관련 없는 기존 동작";
|
|
1497
|
+
if (nextBody === protectedBody.trim()) {
|
|
1498
|
+
return { markdown, changed: false };
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
markdown: replaceMarkdownSection(markdown, "보호 대상", nextBody),
|
|
1502
|
+
changed: true
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function applyRequirementMismatchGuard(markdown, context, candidates) {
|
|
1506
|
+
const taskType = context.taskType ?? classifyTaskType(context.requirement);
|
|
1507
|
+
const checkedSections = ["목표", "현재 문제", "완료 조건", "Codex에게 전달할 주의사항"]
|
|
1508
|
+
.map((section) => readMarkdownSection(markdown, section) ?? "")
|
|
1509
|
+
.join("\n");
|
|
1510
|
+
const drift = analyzeSemanticDrift(context.requirement, checkedSections, taskType);
|
|
1511
|
+
const reasons = detectRequirementMismatch(checkedSections, context.requirement, taskType);
|
|
1512
|
+
if (reasons.length === 0 && drift.severity === "low") {
|
|
1513
|
+
return { markdown, changed: false };
|
|
1514
|
+
}
|
|
1515
|
+
let nextMarkdown = markdown;
|
|
1516
|
+
if (drift.severity === "medium") {
|
|
1517
|
+
nextMarkdown = replaceWeakOrGenericSectionLines(nextMarkdown, "Codex에게 전달할 주의사항", [
|
|
1518
|
+
"- 현재 사용자 요구사항을 기준으로만 수정한다.",
|
|
1519
|
+
"- 이전 context가 다른 주제를 말하면 참고하지 않는다.",
|
|
1520
|
+
"- 애매한 파일은 수정 전에 직접 확인한다."
|
|
1521
|
+
]).markdown;
|
|
1522
|
+
nextMarkdown = applyRequirementInterpretationSection(nextMarkdown, context).markdown;
|
|
1523
|
+
return { markdown: nextMarkdown, changed: true };
|
|
1524
|
+
}
|
|
1525
|
+
if (drift.severity === "high" && taskType.subtype === "bugfix.navigation_state") {
|
|
1526
|
+
const target = inferNavigationStateTarget(context.requirement);
|
|
1527
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "목표", `${target}에서 이전 항목으로 돌아갈 때 원래 보던 항목과 상태가 유지되도록 navigation/state 버그를 수정한다.`);
|
|
1528
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", `${target}에서 이전으로 돌아가면 원래 있던 항목이 유지되지 않고 다른 항목으로 바뀌는 문제가 있다.`);
|
|
1529
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "완료 조건", [
|
|
1530
|
+
"- 이전으로 돌아가도 원래 보던 항목이 다른 항목으로 바뀌지 않는다.",
|
|
1531
|
+
"- 사용자가 선택했던 값과 진행 상태가 의도한 범위에서 유지된다.",
|
|
1532
|
+
"- 결과 페이지 문구나 카피라이팅 수정으로 범위가 바뀌지 않는다.",
|
|
1533
|
+
"- 기존 결과 계산/저장 로직은 유지된다.",
|
|
1534
|
+
"- `pnpm run build`가 성공한다."
|
|
1535
|
+
].join("\n"));
|
|
1536
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "반드시 지킬 규칙", [
|
|
1537
|
+
"- 이전/뒤로 이동 전후의 항목 identity와 선택 상태를 유지한다.",
|
|
1538
|
+
"- 새 질문 흐름이나 결과 페이지 문구 수정으로 범위를 바꾸지 않는다.",
|
|
1539
|
+
"- 상태 저장/복원, navigation history, 선택 값 갱신 지점을 먼저 확인한다.",
|
|
1540
|
+
"- 사용자 요청과 직접 관련 없는 UI copy 변경을 하지 않는다."
|
|
1541
|
+
].join("\n"));
|
|
1542
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "건드리면 안 되는 것", [
|
|
1543
|
+
"- 결과 페이지 문구 수정",
|
|
1544
|
+
"- UI 카피라이팅 정리",
|
|
1545
|
+
"- 결과 계산 로직",
|
|
1546
|
+
"- 사용자 요청과 직접 관련 없는 기존 동작"
|
|
1547
|
+
].join("\n"));
|
|
1548
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "Codex에게 전달할 주의사항", [
|
|
1549
|
+
"- 현재 사용자 요구사항을 기준으로 navigation/state 버그만 수정한다.",
|
|
1550
|
+
"- 이전 task/run/docs의 다른 목표가 섞이면 무시한다.",
|
|
1551
|
+
"- 결과 페이지 문구나 copy 정리 작업으로 바꾸지 않는다.",
|
|
1552
|
+
"- 원래 요구사항과 직접 관련 없는 파일은 수정하지 않는다."
|
|
1553
|
+
].join("\n"));
|
|
1554
|
+
const scopedCandidates = candidates
|
|
1555
|
+
.filter((candidate) => !/(result|결과|wording|copy|text)/i.test(candidate))
|
|
1556
|
+
.slice(0, 6);
|
|
1557
|
+
if (scopedCandidates.length > 0 && isWeakScopeBody(readMarkdownSection(nextMarkdown, "수정 범위") ?? "")) {
|
|
1558
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "수정 범위", scopedCandidates.map((candidate) => `- ${candidate} (후보)`).join("\n"));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
nextMarkdown = applyRequirementInterpretationSection(nextMarkdown, context).markdown;
|
|
1562
|
+
if (drift.severity === "high" && taskType.subtype !== "bugfix.navigation_state") {
|
|
1563
|
+
nextMarkdown = replaceWeakOrGenericSectionLines(nextMarkdown, "Codex에게 전달할 주의사항", [
|
|
1564
|
+
"- 현재 사용자 요구사항을 기준으로만 수정한다.",
|
|
1565
|
+
"- 이전 task/run/docs의 다른 목표가 섞이면 무시한다.",
|
|
1566
|
+
"- 원래 요구사항과 직접 관련 없는 파일은 수정하지 않는다."
|
|
1567
|
+
]).markdown;
|
|
1568
|
+
}
|
|
1569
|
+
return { markdown: nextMarkdown, changed: true };
|
|
1570
|
+
}
|
|
1571
|
+
export function detectRequirementMismatch(text, requirement, taskType = classifyTaskType(requirement)) {
|
|
1572
|
+
const reasons = [];
|
|
1573
|
+
if (!text.trim()) {
|
|
1574
|
+
return reasons;
|
|
1575
|
+
}
|
|
1576
|
+
const drift = analyzeSemanticDrift(requirement, text, taskType);
|
|
1577
|
+
if (drift.severity !== "low") {
|
|
1578
|
+
reasons.push(...drift.reasons);
|
|
1579
|
+
}
|
|
1580
|
+
const normalizedText = text.toLowerCase();
|
|
1581
|
+
const normalizedRequirement = requirement.toLowerCase();
|
|
1582
|
+
if (taskType.subtype === "bugfix.navigation_state") {
|
|
1583
|
+
const hasNavigationSubject = /(이전|뒤로|돌아가|previous|prev|back|navigation|navigate|질문|문제|question|state|상태|유지|바뀌|변경)/i.test(text);
|
|
1584
|
+
const hasTextCleanupSubject = /(결과\s*페이지.*(문구|텍스트|표현|단어)|문구\s*수정|카피라이팅|copy|wording|어색한\s*표현|모호한\s*표현)/i.test(text);
|
|
1585
|
+
if (!hasNavigationSubject) {
|
|
1586
|
+
reasons.push("generated text does not mention navigation/back/question/state subject");
|
|
1587
|
+
}
|
|
1588
|
+
if (hasTextCleanupSubject && !/(문구|텍스트|표현|copy|wording)/i.test(requirement)) {
|
|
1589
|
+
reasons.push("generated text appears to use stale text/result wording context");
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if ((taskType.type === "ui_text_cleanup" || taskType.subtype === "bugfix.text_content") && /(이전|뒤로|돌아가|previous|back|navigation|state|상태)/i.test(text) && !/(이전|뒤로|돌아가|previous|back|navigation|state|상태)/i.test(requirement)) {
|
|
1593
|
+
reasons.push("generated text appears to use stale navigation/state context");
|
|
1594
|
+
}
|
|
1595
|
+
const suspiciousTokens = ["피하는 방향", "모순"];
|
|
1596
|
+
for (const token of suspiciousTokens) {
|
|
1597
|
+
if (normalizedText.includes(token.toLowerCase()) && !normalizedRequirement.includes(token.toLowerCase())) {
|
|
1598
|
+
reasons.push(`generated text includes token not anchored to requirement: ${token}`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (/(^|[^가-힣])결([^가-힣]|$)/.test(text) && !/(^|[^가-힣])결([^가-힣]|$)/.test(requirement)) {
|
|
1602
|
+
reasons.push("generated text includes unexplained shorthand token");
|
|
1603
|
+
}
|
|
1604
|
+
return [...new Set(reasons)];
|
|
1605
|
+
}
|
|
1606
|
+
function extractSectionPathSet(markdown, section) {
|
|
1607
|
+
const body = readMarkdownSection(markdown, section) ?? "";
|
|
1608
|
+
return new Set(body.split("\n").map(extractPathFromLine).filter((path) => Boolean(path)));
|
|
1609
|
+
}
|
|
1610
|
+
function extractPathFromLine(line) {
|
|
1611
|
+
const cleaned = line
|
|
1612
|
+
.trim()
|
|
1613
|
+
.replace(/^[-*]\s*/, "")
|
|
1614
|
+
.replace(/`/g, "")
|
|
1615
|
+
.replace(/\s+\([^)]*\).*$/, "")
|
|
1616
|
+
.replace(/\s+-\s+.*$/, "")
|
|
1617
|
+
.trim();
|
|
1618
|
+
return looksLikeFilePath(cleaned) ? cleaned : undefined;
|
|
1619
|
+
}
|
|
1620
|
+
function looksLikeFilePath(value) {
|
|
1621
|
+
return /^(app|apps|components|src|lib|hooks|utils|pages|packages|supabase|styles|constants|public)\//.test(value);
|
|
1622
|
+
}
|
|
1623
|
+
function applyDirectModificationTargets(markdown, requirement, candidates) {
|
|
1624
|
+
const directTargets = inferDirectModificationTargets(requirement, candidates);
|
|
1625
|
+
if (directTargets.length === 0) {
|
|
1626
|
+
return { markdown, changed: false };
|
|
1627
|
+
}
|
|
1628
|
+
let nextMarkdown = markdown;
|
|
1629
|
+
let changed = false;
|
|
1630
|
+
const targetLines = directTargets.map((target) => `- ${target}`);
|
|
1631
|
+
const targetResult = ensureDirectSectionTargets(nextMarkdown, "수정 대상", targetLines, requirement);
|
|
1632
|
+
nextMarkdown = targetResult.markdown;
|
|
1633
|
+
changed ||= targetResult.changed;
|
|
1634
|
+
const scopeResult = ensureDirectSectionTargets(nextMarkdown, "수정 범위", targetLines, requirement);
|
|
1635
|
+
nextMarkdown = scopeResult.markdown;
|
|
1636
|
+
changed ||= scopeResult.changed;
|
|
1637
|
+
const references = (readMarkdownSection(nextMarkdown, "참고 대상") ?? "")
|
|
1638
|
+
.split("\n")
|
|
1639
|
+
.filter((line) => !directTargets.some((target) => line.includes(target)))
|
|
1640
|
+
.join("\n")
|
|
1641
|
+
.trim();
|
|
1642
|
+
if (references !== (readMarkdownSection(nextMarkdown, "참고 대상") ?? "").trim()) {
|
|
1643
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "참고 대상", references || "- 주변 파일은 필요할 때만 확인한다.");
|
|
1644
|
+
changed = true;
|
|
1645
|
+
}
|
|
1646
|
+
return { markdown: nextMarkdown, changed };
|
|
1647
|
+
}
|
|
1648
|
+
function inferDirectModificationTargets(requirement, candidates) {
|
|
1649
|
+
return analyzeFileRelevance(requirement, candidates)
|
|
1650
|
+
.filter((candidate) => candidate.role === "edit")
|
|
1651
|
+
.map((candidate) => candidate.path)
|
|
1652
|
+
.slice(0, 5);
|
|
1653
|
+
}
|
|
1654
|
+
function ensureDirectSectionTargets(markdown, section, targetLines, requirement) {
|
|
1655
|
+
const existingBody = readMarkdownSection(markdown, section);
|
|
1656
|
+
if (existingBody === undefined || isWeakScopeBody(existingBody)) {
|
|
1657
|
+
return {
|
|
1658
|
+
markdown: replaceMarkdownSection(markdown, section, targetLines.join("\n")),
|
|
1659
|
+
changed: true
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
const cleaned = existingBody
|
|
1663
|
+
.split("\n")
|
|
1664
|
+
.filter((line) => !isIrrelevantTargetLine(line, requirement))
|
|
1665
|
+
.join("\n")
|
|
1666
|
+
.trim();
|
|
1667
|
+
const missing = targetLines.filter((line) => !cleaned.includes(line.replace(/^-\s*/, "")));
|
|
1668
|
+
const nextBody = [cleaned, ...missing].filter(Boolean).join("\n");
|
|
1669
|
+
if (nextBody === existingBody.trim()) {
|
|
1670
|
+
return { markdown, changed: false };
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
markdown: replaceMarkdownSection(markdown, section, nextBody),
|
|
1674
|
+
changed: true
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function isIrrelevantTargetLine(line, requirement) {
|
|
1678
|
+
return conflictingConcepts(requirement).some((token) => tokenizeText(line).includes(token));
|
|
1679
|
+
}
|
|
1680
|
+
function ensureBuildVerification(markdown) {
|
|
1681
|
+
const existingBody = readMarkdownSection(markdown, "검증 명령어");
|
|
1682
|
+
const buildLine = "- `pnpm run build`";
|
|
1683
|
+
if (existingBody === undefined) {
|
|
1684
|
+
return {
|
|
1685
|
+
markdown: `${markdown.trimEnd()}\n\n## 검증 명령어\n${buildLine}\n`,
|
|
1686
|
+
changed: true
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
if (/pnpm\s+run\s+build/.test(existingBody)) {
|
|
1690
|
+
return { markdown, changed: false };
|
|
1691
|
+
}
|
|
1692
|
+
return {
|
|
1693
|
+
markdown: replaceMarkdownSection(markdown, "검증 명령어", `${existingBody.trim()}\n${buildLine}`.trim()),
|
|
1694
|
+
changed: true
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
function fillScopeFromCandidates(markdown, candidates, explicitRoutes) {
|
|
1698
|
+
const candidateList = candidates.filter((candidate) => isScopeCandidate(candidate, explicitRoutes)).slice(0, 8);
|
|
1699
|
+
const explicitScopeItems = [
|
|
1700
|
+
...explicitRoutes.modifyTargets.map((target) => formatTargetLine(target)),
|
|
1701
|
+
...explicitRoutes.deletionTargets.map((target) => formatTargetLine(target, "삭제 대상"))
|
|
1702
|
+
];
|
|
1703
|
+
if (candidateList.length === 0 && explicitScopeItems.length === 0) {
|
|
1704
|
+
return {
|
|
1705
|
+
markdown,
|
|
1706
|
+
scopeFilledFromCandidates: false
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
const candidateScope = formatCandidateScope(candidateList.filter((candidate) => !explicitScopeItems.some((item) => item.includes(candidate))));
|
|
1710
|
+
const scopeBody = [...explicitScopeItems.map((item) => `- ${item}`), candidateScope].filter(Boolean).join("\n");
|
|
1711
|
+
const existingScope = readMarkdownSection(markdown, "수정 범위");
|
|
1712
|
+
if (existingScope === undefined) {
|
|
1713
|
+
return {
|
|
1714
|
+
markdown: `${markdown.trimEnd()}\n\n## 수정 범위\n${scopeBody}\n`,
|
|
1715
|
+
scopeFilledFromCandidates: true
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
const body = existingScope.trim();
|
|
1719
|
+
const withoutUnsupportedAboutRoot = removeProtectedCandidateLines(removeReferenceOnlyScopeLines(removeUnsupportedAboutPage(body, explicitRoutes), explicitRoutes), explicitRoutes);
|
|
1720
|
+
const missingExplicitItems = explicitScopeItems.filter((item) => !withoutUnsupportedAboutRoot.includes(item.split(" ")[0] ?? item));
|
|
1721
|
+
if (!isWeakScopeBody(withoutUnsupportedAboutRoot) && missingExplicitItems.length === 0 && withoutUnsupportedAboutRoot === body) {
|
|
1722
|
+
return {
|
|
1723
|
+
markdown,
|
|
1724
|
+
scopeFilledFromCandidates: false
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
const nextBody = isWeakScopeBody(withoutUnsupportedAboutRoot)
|
|
1728
|
+
? scopeBody
|
|
1729
|
+
: [...missingExplicitItems.map((item) => `- ${item}`), withoutUnsupportedAboutRoot].filter(Boolean).join("\n");
|
|
1730
|
+
return {
|
|
1731
|
+
markdown: replaceMarkdownSection(markdown, "수정 범위", nextBody),
|
|
1732
|
+
scopeFilledFromCandidates: true
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
function isWeakScopeBody(body) {
|
|
1736
|
+
if (!body) {
|
|
1737
|
+
return true;
|
|
1738
|
+
}
|
|
1739
|
+
const normalized = body.replace(/^[-*\s]+/gm, "").trim();
|
|
1740
|
+
return /^(관련 파일 확인 필요|확인 필요|비어 있음|없음|없다|none)$/i.test(normalized);
|
|
1741
|
+
}
|
|
1742
|
+
function formatCandidateScope(candidates) {
|
|
1743
|
+
return candidates.map((candidate) => `- ${candidate} (후보)`).join("\n");
|
|
1744
|
+
}
|
|
1745
|
+
function applyExplicitTargetSections(markdown, explicitRoutes, candidates) {
|
|
1746
|
+
let nextMarkdown = markdown;
|
|
1747
|
+
let changed = false;
|
|
1748
|
+
const modifyLines = [
|
|
1749
|
+
...explicitRoutes.modifyTargets.map((target) => `- ${formatTargetLine(target)}`),
|
|
1750
|
+
...explicitRoutes.deletionTargets.map((target) => `- ${formatTargetLine(target, "삭제 대상")}`)
|
|
1751
|
+
];
|
|
1752
|
+
const referenceLines = [
|
|
1753
|
+
...explicitRoutes.referenceTargets.map((target) => `- ${formatTargetLine(target)}`),
|
|
1754
|
+
...candidates.filter(isLikelyRealFeatureFile).slice(0, 8).map((candidate) => `- ${candidate} (참고 대상)`)
|
|
1755
|
+
];
|
|
1756
|
+
const protectedLines = explicitRoutes.protectedTargets.map((target) => `- ${formatTargetLine(target)}`);
|
|
1757
|
+
const targetResult = ensureSectionLines(nextMarkdown, "수정 대상", modifyLines, explicitRoutes);
|
|
1758
|
+
nextMarkdown = targetResult.markdown;
|
|
1759
|
+
changed ||= targetResult.changed;
|
|
1760
|
+
const referenceResult = ensureSectionLines(nextMarkdown, "참고 대상", referenceLines, explicitRoutes);
|
|
1761
|
+
nextMarkdown = referenceResult.markdown;
|
|
1762
|
+
changed ||= referenceResult.changed;
|
|
1763
|
+
const protectedResult = ensureSectionLines(nextMarkdown, "보호 대상", protectedLines, explicitRoutes);
|
|
1764
|
+
nextMarkdown = protectedResult.markdown;
|
|
1765
|
+
changed ||= protectedResult.changed;
|
|
1766
|
+
if (explicitRoutes.previewIntent) {
|
|
1767
|
+
const ruleLines = [
|
|
1768
|
+
"- 실제 기능 컴포넌트는 기본 수정하지 않는다.",
|
|
1769
|
+
"- 실제 기능 UI는 참고하되 about/service 전용 preview 컴포넌트에서 샘플 데이터로 재현한다.",
|
|
1770
|
+
"- Supabase/Auth/실제 사용자 데이터 fetch 로직은 수정하지 않는다."
|
|
1771
|
+
];
|
|
1772
|
+
const rulesResult = ensureSectionLines(nextMarkdown, "반드시 지킬 규칙", ruleLines, explicitRoutes);
|
|
1773
|
+
nextMarkdown = rulesResult.markdown;
|
|
1774
|
+
changed ||= rulesResult.changed;
|
|
1775
|
+
}
|
|
1776
|
+
return { markdown: nextMarkdown, changed };
|
|
1777
|
+
}
|
|
1778
|
+
function applyIntentQualitySections(markdown, explicitRoutes) {
|
|
1779
|
+
if (!explicitRoutes.previewIntent) {
|
|
1780
|
+
return { markdown, changed: false };
|
|
1781
|
+
}
|
|
1782
|
+
let nextMarkdown = markdown;
|
|
1783
|
+
let changed = false;
|
|
1784
|
+
if (explicitRoutes.modifyTargets.some((target) => target.path === "app/about/service/page.tsx") &&
|
|
1785
|
+
explicitRoutes.deletionTargets.some((target) => target.path === "app/review/kakao/page.tsx")) {
|
|
1786
|
+
const problem = "심사용 서비스 화면이 /about/service와 /review/kakao로 분산되어 있고, 실제 기능 UI와 샘플 화면의 일관성을 /about/service 중심으로 정리해야 한다.";
|
|
1787
|
+
const currentProblem = readMarkdownSection(nextMarkdown, "현재 문제")?.trim() ?? "";
|
|
1788
|
+
if (!currentProblem || /관련 파일.*확인 필요|정확한 수정 지점.*확인/i.test(currentProblem) || !currentProblem.includes("/about/service")) {
|
|
1789
|
+
nextMarkdown = replaceMarkdownSection(nextMarkdown, "현재 문제", problem);
|
|
1790
|
+
changed = true;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const completionLines = [
|
|
1794
|
+
"- `/about/service` 경로로 심사용 서비스 화면에 접속할 수 있다.",
|
|
1795
|
+
"- `/review/kakao` 경로는 제거되었거나 접근할 수 없다.",
|
|
1796
|
+
"- 로그인 없이 샘플 데이터 기반으로 대시보드 나무, 기록 작성, 기록으로 보는 나 화면을 확인할 수 있다.",
|
|
1797
|
+
"- 실제 사용자 데이터 fetch, Supabase 호출, Auth/session 로직을 추가하거나 수정하지 않는다.",
|
|
1798
|
+
"- 실제 dashboard/home/auth 기능 로직을 직접 수정하지 않는다.",
|
|
1799
|
+
"- `pnpm run build`가 성공한다."
|
|
1800
|
+
];
|
|
1801
|
+
const completionResult = ensureSectionLines(nextMarkdown, "완료 조건", completionLines, explicitRoutes);
|
|
1802
|
+
nextMarkdown = completionResult.markdown;
|
|
1803
|
+
changed ||= completionResult.changed;
|
|
1804
|
+
return { markdown: nextMarkdown, changed };
|
|
1805
|
+
}
|
|
1806
|
+
function ensureSectionLines(markdown, section, lines, explicitRoutes) {
|
|
1807
|
+
const uniqueLines = [...new Set(lines.filter(Boolean))];
|
|
1808
|
+
if (uniqueLines.length === 0) {
|
|
1809
|
+
return { markdown, changed: false };
|
|
1810
|
+
}
|
|
1811
|
+
const existingBody = readMarkdownSection(markdown, section);
|
|
1812
|
+
if (existingBody === undefined) {
|
|
1813
|
+
return {
|
|
1814
|
+
markdown: `${markdown.trimEnd()}\n\n## ${section}\n${uniqueLines.join("\n")}\n`,
|
|
1815
|
+
changed: true
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
const rawBody = removeProtectedCandidateLines(removeUnsupportedAboutPage(existingBody.trim(), explicitRoutes), explicitRoutes);
|
|
1819
|
+
const body = isWeakScopeBody(rawBody) ? "" : rawBody;
|
|
1820
|
+
const missingLines = uniqueLines.filter((line) => !body.includes(line.replace(/^-\s+/, "").split(" ")[0] ?? line));
|
|
1821
|
+
if (missingLines.length === 0 && body === existingBody.trim()) {
|
|
1822
|
+
return { markdown, changed: false };
|
|
1823
|
+
}
|
|
1824
|
+
const nextBody = [body, ...missingLines].filter(Boolean).join("\n");
|
|
1825
|
+
return {
|
|
1826
|
+
markdown: replaceMarkdownSection(markdown, section, nextBody),
|
|
1827
|
+
changed: true
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
function readMarkdownSection(markdown, section) {
|
|
1831
|
+
const lines = markdown.split("\n");
|
|
1832
|
+
const start = lines.findIndex((line) => line.trim() === `## ${section}`);
|
|
1833
|
+
if (start < 0) {
|
|
1834
|
+
return undefined;
|
|
1835
|
+
}
|
|
1836
|
+
const body = [];
|
|
1837
|
+
for (const line of lines.slice(start + 1)) {
|
|
1838
|
+
if (/^##\s+/.test(line.trim())) {
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
body.push(line);
|
|
1842
|
+
}
|
|
1843
|
+
return body.join("\n");
|
|
1844
|
+
}
|
|
1845
|
+
function replaceMarkdownSection(markdown, section, body) {
|
|
1846
|
+
const lines = markdown.split("\n");
|
|
1847
|
+
const start = lines.findIndex((line) => line.trim() === `## ${section}`);
|
|
1848
|
+
if (start < 0) {
|
|
1849
|
+
return `${markdown.trimEnd()}\n\n## ${section}\n${body.trim()}\n`;
|
|
1850
|
+
}
|
|
1851
|
+
let end = lines.length;
|
|
1852
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
1853
|
+
if (/^##\s+/.test(lines[index]?.trim() ?? "")) {
|
|
1854
|
+
end = index;
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
return [...lines.slice(0, start + 1), body.trim(), "", ...lines.slice(end)].join("\n").replace(/\n{4,}/g, "\n\n\n");
|
|
1859
|
+
}
|
|
1860
|
+
function inferExplicitRouteTargets(requirement) {
|
|
1861
|
+
const normalized = requirement.toLowerCase();
|
|
1862
|
+
const hasAboutService = /about\/service|about service/i.test(requirement);
|
|
1863
|
+
const hasReviewKakao = /review\/kakao|review kakao/i.test(requirement);
|
|
1864
|
+
const hasDelete = /삭제|delete|remove/i.test(requirement);
|
|
1865
|
+
const previewIntent = /(about\/service|심사|서비스 화면|샘플 데이터|preview|실제 화면 그대로|로그인하지 않아도)/i.test(requirement);
|
|
1866
|
+
const modifyTargets = [];
|
|
1867
|
+
const deletionTargets = [];
|
|
1868
|
+
const referenceTargets = [];
|
|
1869
|
+
const protectedTargets = [];
|
|
1870
|
+
if (hasAboutService) {
|
|
1871
|
+
modifyTargets.push({ path: "app/about/service/page.tsx" });
|
|
1872
|
+
modifyTargets.push({ path: "app/about/service/**", label: "생성/수정 대상" });
|
|
1873
|
+
}
|
|
1874
|
+
if (hasReviewKakao) {
|
|
1875
|
+
const target = { path: "app/review/kakao/page.tsx", label: hasDelete ? "삭제 대상" : "수정 대상" };
|
|
1876
|
+
if (hasDelete) {
|
|
1877
|
+
deletionTargets.push(target);
|
|
1878
|
+
deletionTargets.push({ path: "app/review/kakao/**", label: "삭제 대상" });
|
|
1879
|
+
}
|
|
1880
|
+
else {
|
|
1881
|
+
modifyTargets.push(target);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (previewIntent) {
|
|
1885
|
+
referenceTargets.push({ path: "components/home/*", label: "참고 대상: 실제 홈/나무 UI 구조" });
|
|
1886
|
+
referenceTargets.push({ path: "app/dashboard/page.tsx", label: "참고 대상: 실제 대시보드 화면" });
|
|
1887
|
+
referenceTargets.push({ path: "record 관련 실제 기능 파일", label: "참고 대상" });
|
|
1888
|
+
protectedTargets.push({ path: "components/home/*", label: "보호 대상: 실제 기능 컴포넌트 직접 수정 금지" });
|
|
1889
|
+
protectedTargets.push({ path: "app/dashboard/**", label: "보호 대상: 실제 대시보드 기능 수정 금지" });
|
|
1890
|
+
protectedTargets.push({ path: "lib/supabase/**", label: "보호 대상: 실제 데이터 fetch 수정 금지" });
|
|
1891
|
+
protectedTargets.push({ path: "supabase/**", label: "보호 대상: DB/Edge Function 수정 금지" });
|
|
1892
|
+
protectedTargets.push({ path: "auth/session/login 관련 파일", label: "보호 대상: 인증 로직 수정 금지" });
|
|
1893
|
+
protectedTargets.push({ path: "app/auth/**", label: "보호 대상: auth/callback 수정 금지" });
|
|
1894
|
+
protectedTargets.push({ path: "components/landing/*login*", label: "보호 대상: 로그인 버튼 수정 금지" });
|
|
1895
|
+
protectedTargets.push({ path: "components/landing/*start*", label: "보호 대상: 시작/로그인 유도 버튼 수정 금지" });
|
|
1896
|
+
protectedTargets.push({ path: "lib/auth/**", label: "보호 대상: 인증 유틸 수정 금지" });
|
|
1897
|
+
protectedTargets.push({ path: "supabase auth 관련 파일", label: "보호 대상" });
|
|
1898
|
+
}
|
|
1899
|
+
if (/기록|record/i.test(normalized)) {
|
|
1900
|
+
referenceTargets.push({ path: "record 관련 실제 기능 파일", label: "참고 대상: 화면 구조만 참고" });
|
|
1901
|
+
}
|
|
1902
|
+
return {
|
|
1903
|
+
modifyTargets,
|
|
1904
|
+
deletionTargets,
|
|
1905
|
+
referenceTargets: dedupeTargets(referenceTargets),
|
|
1906
|
+
protectedTargets: dedupeTargets(protectedTargets),
|
|
1907
|
+
previewIntent
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
function formatExplicitRouteGuidance(requirement, candidates) {
|
|
1911
|
+
const targets = inferExplicitRouteTargets(requirement);
|
|
1912
|
+
const sections = [
|
|
1913
|
+
["수정/생성 대상", targets.modifyTargets.map((target) => `- ${formatTargetLine(target)}`)],
|
|
1914
|
+
["삭제 대상", targets.deletionTargets.map((target) => `- ${formatTargetLine(target, "삭제 대상")}`)],
|
|
1915
|
+
["참고 대상", targets.referenceTargets.map((target) => `- ${formatTargetLine(target)}`)],
|
|
1916
|
+
["보호 대상", targets.protectedTargets.map((target) => `- ${formatTargetLine(target)}`)],
|
|
1917
|
+
[
|
|
1918
|
+
"기타 후보",
|
|
1919
|
+
candidates
|
|
1920
|
+
.filter((candidate) => !targets.modifyTargets.some((target) => target.path === candidate))
|
|
1921
|
+
.filter((candidate) => !shouldExcludeExplicitCandidate(candidate, targets))
|
|
1922
|
+
.slice(0, 8)
|
|
1923
|
+
.map((candidate) => `- ${candidate}`)
|
|
1924
|
+
]
|
|
1925
|
+
];
|
|
1926
|
+
return sections
|
|
1927
|
+
.map(([title, lines]) => `### ${title}\n${lines.length > 0 ? lines.join("\n") : "- 없음"}`)
|
|
1928
|
+
.join("\n\n");
|
|
1929
|
+
}
|
|
1930
|
+
function scoreExplicitCandidate(candidate, explicitRoutes) {
|
|
1931
|
+
if (explicitRoutes.modifyTargets.some((target) => target.path === candidate)) {
|
|
1932
|
+
return 100;
|
|
1933
|
+
}
|
|
1934
|
+
if (explicitRoutes.deletionTargets.some((target) => target.path === candidate)) {
|
|
1935
|
+
return 95;
|
|
1936
|
+
}
|
|
1937
|
+
if (/^app\/about\/page\.(tsx|ts|jsx|js)$/i.test(candidate) && explicitRoutes.modifyTargets.some((target) => target.path.startsWith("app/about/service/"))) {
|
|
1938
|
+
return -100;
|
|
1939
|
+
}
|
|
1940
|
+
if (isLikelyRealFeatureFile(candidate) && explicitRoutes.previewIntent) {
|
|
1941
|
+
return 10;
|
|
1942
|
+
}
|
|
1943
|
+
return 0;
|
|
1944
|
+
}
|
|
1945
|
+
function isResultPageRequest(requirement) {
|
|
1946
|
+
return /result|결과\s*페이지|결과/.test(requirement.toLowerCase());
|
|
1947
|
+
}
|
|
1948
|
+
function isQuestionRequest(requirement) {
|
|
1949
|
+
return /question|choice|질문|문항|선택/.test(requirement.toLowerCase());
|
|
1950
|
+
}
|
|
1951
|
+
function shouldExcludeExplicitCandidate(candidate, explicitRoutes) {
|
|
1952
|
+
if (/^app\/about\/page\.(tsx|ts|jsx|js)$/i.test(candidate) && explicitRoutes.modifyTargets.some((target) => target.path.startsWith("app/about/service/"))) {
|
|
1953
|
+
return true;
|
|
1954
|
+
}
|
|
1955
|
+
return explicitRoutes.previewIntent && isAuthLoginFile(candidate);
|
|
1956
|
+
}
|
|
1957
|
+
function isLikelyRealFeatureFile(path) {
|
|
1958
|
+
return (path.startsWith("components/home/") ||
|
|
1959
|
+
path.startsWith("app/dashboard/") ||
|
|
1960
|
+
/record/i.test(path) ||
|
|
1961
|
+
/tree/i.test(path) ||
|
|
1962
|
+
/feedback/i.test(path));
|
|
1963
|
+
}
|
|
1964
|
+
function isScopeCandidate(path, explicitRoutes) {
|
|
1965
|
+
if (/^app\/about\/page\.(tsx|ts|jsx|js)$/i.test(path) && explicitRoutes.modifyTargets.some((target) => target.path.startsWith("app/about/service/"))) {
|
|
1966
|
+
return false;
|
|
1967
|
+
}
|
|
1968
|
+
if (explicitRoutes.previewIntent && isLikelyRealFeatureFile(path)) {
|
|
1969
|
+
return false;
|
|
1970
|
+
}
|
|
1971
|
+
if (explicitRoutes.previewIntent && isAuthLoginFile(path)) {
|
|
1972
|
+
return false;
|
|
1973
|
+
}
|
|
1974
|
+
return true;
|
|
1975
|
+
}
|
|
1976
|
+
function removeReferenceOnlyScopeLines(body, explicitRoutes) {
|
|
1977
|
+
if (!explicitRoutes.previewIntent) {
|
|
1978
|
+
return body;
|
|
1979
|
+
}
|
|
1980
|
+
return body
|
|
1981
|
+
.split("\n")
|
|
1982
|
+
.filter((line) => {
|
|
1983
|
+
const normalized = line.replace(/^[-*\s]+/, "").trim();
|
|
1984
|
+
return !isLikelyRealFeatureFile(normalized) && !isAuthLoginFile(normalized);
|
|
1985
|
+
})
|
|
1986
|
+
.join("\n")
|
|
1987
|
+
.trim();
|
|
1988
|
+
}
|
|
1989
|
+
function removeProtectedCandidateLines(body, explicitRoutes) {
|
|
1990
|
+
if (!explicitRoutes.previewIntent) {
|
|
1991
|
+
return body;
|
|
1992
|
+
}
|
|
1993
|
+
return body
|
|
1994
|
+
.split("\n")
|
|
1995
|
+
.filter((line) => !isAuthLoginFile(line.replace(/^[-*\s]+/, "").trim()))
|
|
1996
|
+
.join("\n")
|
|
1997
|
+
.trim();
|
|
1998
|
+
}
|
|
1999
|
+
function formatTargetLine(target, fallbackLabel) {
|
|
2000
|
+
const label = target.label ?? fallbackLabel;
|
|
2001
|
+
return label ? `${target.path} (${label})` : target.path;
|
|
2002
|
+
}
|
|
2003
|
+
function isAuthLoginFile(path) {
|
|
2004
|
+
const normalized = path
|
|
2005
|
+
.trim()
|
|
2006
|
+
.replace(/^[-*\s]+/, "")
|
|
2007
|
+
.replace(/\s+\([^)]*\)$/, "")
|
|
2008
|
+
.replace(/\s+-\s+.*$/, "");
|
|
2009
|
+
return (/^app\/auth\//i.test(normalized) ||
|
|
2010
|
+
/^lib\/auth\//i.test(normalized) ||
|
|
2011
|
+
/^components\/landing\/.*login.*\.(tsx|ts|jsx|js)$/i.test(normalized) ||
|
|
2012
|
+
/^components\/landing\/.*start.*\.(tsx|ts|jsx|js)$/i.test(normalized) ||
|
|
2013
|
+
/auth\/callback/i.test(normalized) ||
|
|
2014
|
+
/supabase.*auth|auth.*supabase/i.test(normalized));
|
|
2015
|
+
}
|
|
2016
|
+
function removeUnsupportedAboutPage(body, explicitRoutes) {
|
|
2017
|
+
if (!explicitRoutes.modifyTargets.some((target) => target.path.startsWith("app/about/service/"))) {
|
|
2018
|
+
return body;
|
|
2019
|
+
}
|
|
2020
|
+
return body
|
|
2021
|
+
.split("\n")
|
|
2022
|
+
.filter((line) => !/app\/about\/page\.(tsx|ts|jsx|js)/i.test(line))
|
|
2023
|
+
.join("\n")
|
|
2024
|
+
.trim();
|
|
2025
|
+
}
|
|
2026
|
+
function dedupeTargets(targets) {
|
|
2027
|
+
const seen = new Set();
|
|
2028
|
+
const result = [];
|
|
2029
|
+
for (const target of targets) {
|
|
2030
|
+
const key = `${target.path}:${target.label ?? ""}`;
|
|
2031
|
+
if (seen.has(key)) {
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
seen.add(key);
|
|
2035
|
+
result.push(target);
|
|
2036
|
+
}
|
|
2037
|
+
return result;
|
|
2038
|
+
}
|
|
2039
|
+
function escapeRegExp(value) {
|
|
2040
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2041
|
+
}
|
|
2042
|
+
function hasUnsupportedEmailPasswordSpeculation(requirement, markdown) {
|
|
2043
|
+
const mentionsKakaoLogin = /(kakao|카카오).*(login|로그인)|(login|로그인).*(kakao|카카오)/i.test(requirement);
|
|
2044
|
+
const userMentionedEmailPassword = /(email|이메일|password|비밀번호)/i.test(requirement);
|
|
2045
|
+
if (!mentionsKakaoLogin || userMentionedEmailPassword) {
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
return /(email|이메일|password|비밀번호)/i.test(markdown);
|
|
2049
|
+
}
|
|
2050
|
+
//# sourceMappingURL=ai.js.map
|