@dev-guard/cli 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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/agent-strategies.d.ts +23 -0
  4. package/dist/agent-strategies.js +130 -0
  5. package/dist/agent-strategies.js.map +1 -0
  6. package/dist/ai-context.d.ts +10 -0
  7. package/dist/ai-context.js +143 -0
  8. package/dist/ai-context.js.map +1 -0
  9. package/dist/check.d.ts +6 -0
  10. package/dist/check.js +89 -0
  11. package/dist/check.js.map +1 -0
  12. package/dist/clipboard.d.ts +6 -0
  13. package/dist/clipboard.js +43 -0
  14. package/dist/clipboard.js.map +1 -0
  15. package/dist/codex-notify.d.ts +23 -0
  16. package/dist/codex-notify.js +146 -0
  17. package/dist/codex-notify.js.map +1 -0
  18. package/dist/command-targets.d.ts +10 -0
  19. package/dist/command-targets.js +124 -0
  20. package/dist/command-targets.js.map +1 -0
  21. package/dist/config.d.ts +22 -0
  22. package/dist/config.js +180 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/configure.d.ts +1 -0
  25. package/dist/configure.js +79 -0
  26. package/dist/configure.js.map +1 -0
  27. package/dist/doctor.d.ts +1 -0
  28. package/dist/doctor.js +326 -0
  29. package/dist/doctor.js.map +1 -0
  30. package/dist/drift-telemetry.d.ts +13 -0
  31. package/dist/drift-telemetry.js +64 -0
  32. package/dist/drift-telemetry.js.map +1 -0
  33. package/dist/effective-task.d.ts +44 -0
  34. package/dist/effective-task.js +355 -0
  35. package/dist/effective-task.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.js +58 -0
  38. package/dist/fs.js.map +1 -0
  39. package/dist/git.d.ts +24 -0
  40. package/dist/git.js +235 -0
  41. package/dist/git.js.map +1 -0
  42. package/dist/hooks.d.ts +39 -0
  43. package/dist/hooks.js +513 -0
  44. package/dist/hooks.js.map +1 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +555 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/infer-task.d.ts +1 -0
  49. package/dist/infer-task.js +120 -0
  50. package/dist/infer-task.js.map +1 -0
  51. package/dist/init.d.ts +1 -0
  52. package/dist/init.js +50 -0
  53. package/dist/init.js.map +1 -0
  54. package/dist/install-agent-instructions.d.ts +1 -0
  55. package/dist/install-agent-instructions.js +113 -0
  56. package/dist/install-agent-instructions.js.map +1 -0
  57. package/dist/migration.d.ts +8 -0
  58. package/dist/migration.js +43 -0
  59. package/dist/migration.js.map +1 -0
  60. package/dist/paths.d.ts +38 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/paths.js.map +1 -0
  63. package/dist/project-detection.d.ts +10 -0
  64. package/dist/project-detection.js +144 -0
  65. package/dist/project-detection.js.map +1 -0
  66. package/dist/project-identity.d.ts +7 -0
  67. package/dist/project-identity.js +93 -0
  68. package/dist/project-identity.js.map +1 -0
  69. package/dist/project-memory.d.ts +4 -0
  70. package/dist/project-memory.js +32 -0
  71. package/dist/project-memory.js.map +1 -0
  72. package/dist/prompt.d.ts +13 -0
  73. package/dist/prompt.js +125 -0
  74. package/dist/prompt.js.map +1 -0
  75. package/dist/refresh.d.ts +15 -0
  76. package/dist/refresh.js +146 -0
  77. package/dist/refresh.js.map +1 -0
  78. package/dist/report.d.ts +1 -0
  79. package/dist/report.js +109 -0
  80. package/dist/report.js.map +1 -0
  81. package/dist/review.d.ts +2 -0
  82. package/dist/review.js +653 -0
  83. package/dist/review.js.map +1 -0
  84. package/dist/rule-filter.d.ts +8 -0
  85. package/dist/rule-filter.js +79 -0
  86. package/dist/rule-filter.js.map +1 -0
  87. package/dist/runs.d.ts +21 -0
  88. package/dist/runs.js +142 -0
  89. package/dist/runs.js.map +1 -0
  90. package/dist/runtime-state.d.ts +69 -0
  91. package/dist/runtime-state.js +1383 -0
  92. package/dist/runtime-state.js.map +1 -0
  93. package/dist/scan.d.ts +1 -0
  94. package/dist/scan.js +55 -0
  95. package/dist/scan.js.map +1 -0
  96. package/dist/self.d.ts +3 -0
  97. package/dist/self.js +235 -0
  98. package/dist/self.js.map +1 -0
  99. package/dist/task-ai.d.ts +1 -0
  100. package/dist/task-ai.js +643 -0
  101. package/dist/task-ai.js.map +1 -0
  102. package/dist/telemetry.d.ts +1 -0
  103. package/dist/telemetry.js +11 -0
  104. package/dist/telemetry.js.map +1 -0
  105. package/dist/update.d.ts +6 -0
  106. package/dist/update.js +154 -0
  107. package/dist/update.js.map +1 -0
  108. package/dist/watch.d.ts +1 -0
  109. package/dist/watch.js +303 -0
  110. package/dist/watch.js.map +1 -0
  111. package/package.json +31 -0
@@ -0,0 +1,1383 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { mkdir, rename, stat, writeFile } from "node:fs/promises";
5
+ import { promisify } from "node:util";
6
+ import { analyzeDiff, defaultConfig, filterDevGuardContextFiles, formatInferredDiffIntentClusters, generateUpdateSuggestions, inferDiffIntentClusters } from "@dev-guard/core";
7
+ import { appendTextFile, fromRoot, readJsonFile, readTextFile, writeFileIfMissing, writeTextFile } from "./fs.js";
8
+ import { getDiffForChangeFiles, getGitChanges } from "./git.js";
9
+ import { migrateLegacyDevguardDir } from "./migration.js";
10
+ import { devguardPaths } from "./paths.js";
11
+ const execFileAsync = promisify(execFile);
12
+ const runtimePath = devguardPaths.runtime;
13
+ const statePath = devguardPaths.state;
14
+ const historyPath = devguardPaths.history;
15
+ const reportPath = devguardPaths.lastRunReport;
16
+ const promptPath = devguardPaths.nextCodexPrompt;
17
+ const historySummaryPath = devguardPaths.historySummary;
18
+ const decisionCandidatesPath = devguardPaths.decisionCandidates;
19
+ const qualityReportPath = devguardPaths.qualityReport;
20
+ const projectHandoffPath = devguardPaths.projectHandoff;
21
+ const hookStatusPath = devguardPaths.hookStatus;
22
+ const defaultRuntime = {
23
+ pendingChangedFiles: [],
24
+ lastStatus: "idle"
25
+ };
26
+ export async function readRuntimeState(root) {
27
+ await ensureDevguardWorkspace(root);
28
+ try {
29
+ return readJsonFile(fromRoot(root, runtimePath), defaultRuntime);
30
+ }
31
+ catch {
32
+ return defaultRuntime;
33
+ }
34
+ }
35
+ export async function writeRuntimeState(root, state) {
36
+ await ensureDevguardWorkspace(root);
37
+ try {
38
+ await writeAtomicTextFile(fromRoot(root, runtimePath), `${JSON.stringify(normalizeRuntimeState(state), null, 2)}\n`);
39
+ }
40
+ catch (error) {
41
+ await logRuntimeWriteWarning(root, `runtime_write=failed path=${runtimePath} error=${quoteLogValue(errorMessage(error))}`);
42
+ }
43
+ }
44
+ export async function resetRuntimeState(root) {
45
+ await writeRuntimeState(root, defaultRuntime);
46
+ }
47
+ export async function readProjectState(root) {
48
+ await ensureDevguardWorkspace(root);
49
+ return readJsonFile(fromRoot(root, statePath), {});
50
+ }
51
+ export async function writeProjectState(root, state) {
52
+ await ensureDevguardWorkspace(root);
53
+ await writeAtomicTextFile(fromRoot(root, statePath), `${JSON.stringify(state, null, 2)}\n`);
54
+ }
55
+ export async function readHistoryRecords(root, limit = 20) {
56
+ await ensureDevguardWorkspace(root);
57
+ const text = await readTextFile(fromRoot(root, historyPath));
58
+ const records = text
59
+ .split(/\r?\n/)
60
+ .map((line) => line.trim())
61
+ .filter(Boolean)
62
+ .map((line) => {
63
+ try {
64
+ return JSON.parse(line);
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ })
70
+ .filter((record) => Boolean(record));
71
+ return records.slice(-limit);
72
+ }
73
+ export async function recordRuntimeChange(root, path) {
74
+ const normalized = normalizeEventPath(root, path);
75
+ if (!normalized || isIgnoredWatchPath(normalized)) {
76
+ return readRuntimeState(root);
77
+ }
78
+ const now = new Date().toISOString();
79
+ const current = await readRuntimeState(root);
80
+ const pendingChangedFiles = [...new Set([...current.pendingChangedFiles, normalized])].sort();
81
+ const next = {
82
+ ...current,
83
+ pendingChangedFiles,
84
+ firstChangedAt: current.firstChangedAt ?? now,
85
+ lastChangedAt: now,
86
+ lastStatus: "active"
87
+ };
88
+ await writeRuntimeState(root, next);
89
+ return next;
90
+ }
91
+ export async function markRuntimeStable(root, diffHash) {
92
+ const current = await readRuntimeState(root);
93
+ const project = await readProjectState(root);
94
+ if (isRuntimeOlderThanProcessed(current, project.lastProcessedAt)) {
95
+ await logRuntimeWriteWarning(root, "runtime_write=skipped reason=stale_stable_after_done");
96
+ await writeRuntimeState(root, defaultRuntime);
97
+ return defaultRuntime;
98
+ }
99
+ const next = {
100
+ ...current,
101
+ lastStableAt: new Date().toISOString(),
102
+ lastDiffHash: diffHash,
103
+ lastStatus: current.pendingChangedFiles.length > 0 ? "ready_for_done" : "idle"
104
+ };
105
+ await writeRuntimeState(root, next);
106
+ return next;
107
+ }
108
+ export function isIgnoredWatchPath(path) {
109
+ const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
110
+ return (normalized.startsWith("node_modules/") ||
111
+ normalized.startsWith(".next/") ||
112
+ normalized.startsWith("dist/") ||
113
+ normalized.startsWith("build/") ||
114
+ normalized.startsWith(".git/") ||
115
+ normalized.startsWith("coverage/") ||
116
+ normalized === runtimePath ||
117
+ normalized === devguardPaths.state ||
118
+ normalized === devguardPaths.history ||
119
+ isPathOrChild(normalized, devguardPaths.reportsDir) ||
120
+ isPathOrChild(normalized, devguardPaths.promptsDir) ||
121
+ isPathOrChild(normalized, devguardPaths.contextDir) ||
122
+ isPathOrChild(normalized, devguardPaths.logsDir) ||
123
+ isPathOrChild(normalized, devguardPaths.hooksDir) ||
124
+ /\.(png|jpe?g|gif|webp|avif|ico|svg|ttf|otf|woff2?|mp4|mov|mp3|wav|pdf|zip|gz)$/i.test(normalized));
125
+ }
126
+ function isPathOrChild(path, parent) {
127
+ return path === parent || path.startsWith(`${parent}/`);
128
+ }
129
+ async function writeAtomicTextFile(path, content) {
130
+ await mkdir(dirname(path), { recursive: true });
131
+ let lastError;
132
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
133
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomSuffix()}.tmp`;
134
+ try {
135
+ await writeFile(tempPath, content, "utf8");
136
+ await stat(tempPath);
137
+ await rename(tempPath, path);
138
+ return;
139
+ }
140
+ catch (error) {
141
+ lastError = error;
142
+ await logAtomicWriteWarning(path, `atomic_write=retry attempt=${attempt} path=${quoteLogValue(path)} error=${quoteLogValue(errorMessage(error))}`);
143
+ await sleep(25 * attempt);
144
+ }
145
+ }
146
+ await logAtomicWriteWarning(path, `atomic_write=failed path=${quoteLogValue(path)} error=${quoteLogValue(errorMessage(lastError))}`);
147
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
148
+ }
149
+ export async function processDoneEvent(root) {
150
+ await ensureDevguardWorkspace(root);
151
+ const runtime = await readRuntimeState(root);
152
+ const gitChanges = await loadChangesWithFallback(root, runtime);
153
+ const changeFiles = filterDevGuardContextFiles(gitChanges.changeFiles, false);
154
+ const rawChangedFiles = [...new Set(gitChanges.changeFiles.map((file) => file.path))].sort();
155
+ const changedFiles = [
156
+ ...new Set((changeFiles.length > 0 ? changeFiles.map((file) => file.path) : runtime.pendingChangedFiles).filter((file) => !isIgnoredWatchPath(file) && !isDevguardManagedDocPath(file)))
157
+ ].sort();
158
+ const diffText = changeFiles.length > 0 ? await getDiffForChangeFiles(root, changeFiles).catch(() => gitChanges.diffText) : gitChanges.diffText;
159
+ const diffStat = await getGitDiffStat(root).catch(() => "git diff stat unavailable");
160
+ const [projectMarkdown, architectureMarkdown, decisionsMarkdown, tasksMarkdown, config, codeGraph] = await Promise.all([
161
+ readTextFile(fromRoot(root, devguardPaths.project)),
162
+ readTextFile(fromRoot(root, devguardPaths.architecture)),
163
+ readTextFile(fromRoot(root, devguardPaths.decisions)),
164
+ readTextFile(fromRoot(root, devguardPaths.tasks)),
165
+ readJsonFile(fromRoot(root, ".devguard/config.json"), defaultConfig),
166
+ readJsonFile(fromRoot(root, ".devguard/code-graph.json"), [])
167
+ ]);
168
+ const clusters = inferDiffIntentClusters({ changedFiles, changeFiles, diffText, codeGraph });
169
+ const checkReport = analyzeDiff({
170
+ changedFiles,
171
+ changeFiles,
172
+ diffText,
173
+ taskText: [tasksMarkdown, projectMarkdown, architectureMarkdown].join("\n\n"),
174
+ rulesText: decisionsMarkdown,
175
+ config,
176
+ includeContextFiles: false
177
+ });
178
+ const updateSuggestions = generateUpdateSuggestions({
179
+ changedFiles,
180
+ changeFiles,
181
+ diffText,
182
+ taskMarkdown: tasksMarkdown || projectMarkdown || "No active task document.",
183
+ rulesMarkdown: decisionsMarkdown,
184
+ mistakesMarkdown: architectureMarkdown,
185
+ includeContextFiles: false
186
+ });
187
+ const areas = classifyAreas(changedFiles);
188
+ const judgments = buildJudgments({ areas, clusters, checkFindings: checkReport.findings.map((finding) => finding.message), architectureMarkdown, decisionsMarkdown });
189
+ const drift = clusters.mixedRisk;
190
+ const summary = formatInferredDiffIntentClusters(clusters);
191
+ const timestamp = new Date().toISOString();
192
+ const majorChanges = inferMajorChanges({ summary, changedFiles, areas, diffText });
193
+ const testCandidates = await inferTestCandidates(root, { areas, changedFiles });
194
+ const projectContext = summarizeProjectContext({ projectMarkdown, architectureMarkdown, decisionsMarkdown });
195
+ const docUpdateCandidates = [updateSuggestions.summary];
196
+ const historyRecord = {
197
+ id: `run_${timestamp.replace(/[-:.]/g, "").slice(0, 15)}_${hashRuntimeFiles(changedFiles).slice(0, 6)}`,
198
+ timestamp,
199
+ changedFiles,
200
+ areas,
201
+ diffStat,
202
+ inferredSummary: summary,
203
+ driftCandidates: judgments,
204
+ docUpdateCandidates,
205
+ testCandidates,
206
+ generatedPromptPath: promptPath,
207
+ reportPath
208
+ };
209
+ const previousHistory = await readHistoryRecords(root, 20);
210
+ const nextHistory = [...previousHistory, historyRecord];
211
+ const decisionCandidates = inferDecisionCandidates({ areas, changedFiles, judgments, summary });
212
+ const riskDetails = buildRiskDetails({ judgments, changedFiles, areas });
213
+ const nextTask = chooseNextTask({ drift, judgments, areas, changedFiles, testCandidates });
214
+ const qualityReport = await assessCompletionQuality(root, {
215
+ changedFiles,
216
+ rawChangedFiles,
217
+ areas,
218
+ judgments,
219
+ testCandidates,
220
+ nextTaskTitle: nextTask.title
221
+ });
222
+ const reportMarkdown = renderLastRunReport({
223
+ timestamp,
224
+ changedFiles,
225
+ areas,
226
+ judgments,
227
+ summary,
228
+ drift,
229
+ updateSummary: updateSuggestions.summary,
230
+ diffStat,
231
+ majorChanges,
232
+ testCandidates
233
+ });
234
+ const historySummaryMarkdown = renderHistorySummary(nextHistory);
235
+ const decisionCandidatesMarkdown = renderDecisionCandidates({
236
+ timestamp,
237
+ summary,
238
+ candidates: decisionCandidates,
239
+ changedFiles
240
+ });
241
+ const qualityReportMarkdown = renderQualityReport(qualityReport);
242
+ const promptMarkdown = renderNextPrompt({
243
+ projectContext,
244
+ summary,
245
+ changedFiles,
246
+ areas,
247
+ judgments,
248
+ drift,
249
+ testCommands: testCandidates,
250
+ recentHistory: nextHistory.slice(-5),
251
+ decisionCandidates,
252
+ riskDetails,
253
+ nextTask,
254
+ qualityReport
255
+ });
256
+ await Promise.all([
257
+ appendTextFile(fromRoot(root, historyPath), `${JSON.stringify(historyRecord)}\n`),
258
+ writeTextFile(fromRoot(root, reportPath), reportMarkdown),
259
+ writeTextFile(fromRoot(root, historySummaryPath), historySummaryMarkdown),
260
+ writeTextFile(fromRoot(root, decisionCandidatesPath), decisionCandidatesMarkdown),
261
+ writeTextFile(fromRoot(root, qualityReportPath), qualityReportMarkdown),
262
+ writeTextFile(fromRoot(root, promptPath), promptMarkdown),
263
+ writeProjectState(root, {
264
+ ...(await readProjectState(root)),
265
+ lastProcessedAt: new Date().toISOString(),
266
+ lastSummary: summary,
267
+ lastDrift: drift,
268
+ lastQualityVerdict: qualityReport.verdict,
269
+ lastQualityNextAction: qualityReport.nextRecommendedAction,
270
+ lastChangedFiles: changedFiles,
271
+ lastReportPath: reportPath,
272
+ lastPromptPath: promptPath,
273
+ lastHandoffPath: projectHandoffPath
274
+ }),
275
+ resetRuntimeState(root)
276
+ ]);
277
+ await Promise.all([
278
+ generateProjectHandoff(root),
279
+ generateAgentContext(root),
280
+ generateNextClaudePrompt(root)
281
+ ]);
282
+ return {
283
+ changedFiles,
284
+ areas,
285
+ judgments,
286
+ reportPath,
287
+ promptPath,
288
+ historySummaryPath,
289
+ decisionCandidatesPath,
290
+ qualityReportPath,
291
+ projectHandoffPath,
292
+ agentContextPath: devguardPaths.agentContext,
293
+ nextClaudePromptPath: devguardPaths.nextClaudePrompt,
294
+ qualityVerdict: qualityReport.verdict,
295
+ summary,
296
+ drift
297
+ };
298
+ }
299
+ export async function generateProjectHandoff(root) {
300
+ await ensureDevguardWorkspace(root);
301
+ const [project, architecture, decisions, tasks, history, historySummary, decisionCandidates, qualityReport, nextPrompt, hookStatus, state] = await Promise.all([
302
+ readRequiredText(root, devguardPaths.project),
303
+ readRequiredText(root, devguardPaths.architecture),
304
+ readRequiredText(root, devguardPaths.decisions),
305
+ readRequiredText(root, devguardPaths.tasks),
306
+ readRequiredText(root, historyPath),
307
+ readRequiredText(root, historySummaryPath),
308
+ readRequiredText(root, decisionCandidatesPath),
309
+ readRequiredText(root, qualityReportPath),
310
+ readRequiredText(root, promptPath),
311
+ readRequiredText(root, hookStatusPath),
312
+ readJsonFile(fromRoot(root, statePath), {})
313
+ .then((value) => JSON.stringify(value, null, 2))
314
+ .catch(() => "확인 필요")
315
+ ]);
316
+ const records = parseHistoryRecords(history.content).slice(-5);
317
+ const handoff = renderProjectHandoff({
318
+ project,
319
+ architecture,
320
+ decisions,
321
+ tasks,
322
+ records,
323
+ historySummary,
324
+ decisionCandidates,
325
+ qualityReport,
326
+ nextPrompt,
327
+ hookStatus,
328
+ state
329
+ });
330
+ await writeTextFile(fromRoot(root, projectHandoffPath), handoff);
331
+ return projectHandoffPath;
332
+ }
333
+ export async function generateAgentContext(root) {
334
+ await ensureDevguardWorkspace(root);
335
+ const [project, decisions, qualityContent, historyRecords, state] = await Promise.all([
336
+ readTextFile(fromRoot(root, devguardPaths.project)),
337
+ readTextFile(fromRoot(root, devguardPaths.decisions)),
338
+ readTextFile(fromRoot(root, qualityReportPath)),
339
+ readHistoryRecords(root, 5),
340
+ readJsonFile(fromRoot(root, statePath), {})
341
+ ]);
342
+ const nextPromptContent = await readTextFile(fromRoot(root, promptPath));
343
+ const projectPurpose = firstSectionBullet(project, "프로젝트 목적") ?? "확인 필요";
344
+ const currentGoal = firstSectionBullet(project, "현재 목표") ?? "확인 필요";
345
+ const quality = parseQuality(qualityContent);
346
+ const nextTask = extractNextTask(nextPromptContent, "", JSON.stringify(state));
347
+ const importantDecisions = extractDecisionLines(decisions);
348
+ const lastChangedFiles = state.lastChangedFiles ?? [];
349
+ const lastSummary = state.lastSummary ?? "확인 필요";
350
+ const recentHistory = historyRecords
351
+ .slice(-3)
352
+ .reverse()
353
+ .map((r) => `${r.timestamp}: ${r.inferredSummary}`);
354
+ const markdown = renderAgentContext({
355
+ projectPurpose,
356
+ currentGoal,
357
+ lastSummary,
358
+ recentHistory,
359
+ lastChangedFiles,
360
+ qualityVerdict: quality.verdict,
361
+ qualityWhy: quality.why,
362
+ nextBestTask: nextTask,
363
+ importantDecisions
364
+ });
365
+ await mkdir(fromRoot(root, devguardPaths.contextDir), { recursive: true });
366
+ await writeTextFile(fromRoot(root, devguardPaths.agentContext), markdown);
367
+ return devguardPaths.agentContext;
368
+ }
369
+ export async function generateNextClaudePrompt(root) {
370
+ await ensureDevguardWorkspace(root);
371
+ const [qualityContent, state] = await Promise.all([
372
+ readTextFile(fromRoot(root, qualityReportPath)),
373
+ readJsonFile(fromRoot(root, statePath), {})
374
+ ]);
375
+ const nextPromptContent = await readTextFile(fromRoot(root, promptPath));
376
+ const quality = parseQuality(qualityContent);
377
+ const nextTask = extractNextTask(nextPromptContent, "", JSON.stringify(state));
378
+ const markdown = renderNextClaudePrompt({ qualityVerdict: quality.verdict, nextBestTask: nextTask });
379
+ await writeTextFile(fromRoot(root, devguardPaths.nextClaudePrompt), markdown);
380
+ return devguardPaths.nextClaudePrompt;
381
+ }
382
+ function renderAgentContext(input) {
383
+ return [
384
+ "# Agent Context",
385
+ "",
386
+ "> dev-guard generated — read this before exploring the repository.",
387
+ "",
388
+ "## Current State",
389
+ `- project purpose: ${input.projectPurpose}`,
390
+ `- current goal: ${input.currentGoal}`,
391
+ "",
392
+ "## Last Completed Work",
393
+ `- ${input.lastSummary}`,
394
+ ...input.recentHistory.map((line) => `- ${line}`),
395
+ "",
396
+ "## Quality Status",
397
+ `- verdict: ${input.qualityVerdict}`,
398
+ ...(input.qualityWhy.length > 0 && input.qualityWhy[0] !== "확인 필요"
399
+ ? ["- reason:", ...input.qualityWhy.map((item) => ` - ${item}`)]
400
+ : []),
401
+ "",
402
+ "## Next Best Task",
403
+ `- ${input.nextBestTask}`,
404
+ "",
405
+ "## Important Decisions",
406
+ ...formatBullets(input.importantDecisions),
407
+ "",
408
+ "## Important Files",
409
+ ...formatBullets(input.lastChangedFiles.slice(0, 10).length > 0 ? input.lastChangedFiles.slice(0, 10) : ["확인 필요"]),
410
+ "",
411
+ "## Do Not Touch",
412
+ `- \`${devguardPaths.reportsDir}\`, \`${devguardPaths.promptsDir}\`, \`${devguardPaths.contextDir}\`, \`${devguardPaths.runtime}\` — auto-generated by dev-guard`,
413
+ "- existing public command UX: watch / done / status / reset",
414
+ "- auth / database / api / config unless directly required by current task",
415
+ "- large refactors not explicitly requested",
416
+ "",
417
+ "## Additional Context",
418
+ `- full handoff: \`${devguardPaths.projectHandoff}\``,
419
+ `- architecture: \`${devguardPaths.architecture}\``,
420
+ `- decisions: \`${devguardPaths.decisions}\``,
421
+ `- quality report: \`${devguardPaths.qualityReport}\``,
422
+ `- next Codex prompt: \`${devguardPaths.nextCodexPrompt}\``
423
+ ].join("\n") + "\n";
424
+ }
425
+ function renderNextClaudePrompt(input) {
426
+ return [
427
+ "# Next Claude Prompt",
428
+ "",
429
+ "Before starting any work, read:",
430
+ "",
431
+ `1. \`${devguardPaths.agentContext}\` — current state, quality status, next task`,
432
+ `2. \`${devguardPaths.projectHandoff}\` — compressed project resume`,
433
+ `3. \`${devguardPaths.qualityReport}\` — quality verdict and required verification`,
434
+ "",
435
+ "Use dev-guard context as the primary source of project state.",
436
+ "Do not perform repository-wide scans before reading them.",
437
+ "Only open additional files when specifically required for the current task.",
438
+ "",
439
+ "---",
440
+ "",
441
+ `Quality: **${input.qualityVerdict}**`,
442
+ "",
443
+ `Next Task: ${input.nextBestTask}`
444
+ ].join("\n") + "\n";
445
+ }
446
+ export function classifyAreas(files) {
447
+ const areas = new Set();
448
+ for (const file of files) {
449
+ if (/(^|\/)(auth|session|login|middleware)\b/i.test(file))
450
+ areas.add("auth");
451
+ if (/(^|\/)(db|database|schema|migration|supabase)\b/i.test(file))
452
+ areas.add("database");
453
+ if (/^(app\/api|pages\/api|src\/app\/api)\//i.test(file))
454
+ areas.add("api");
455
+ if (/(^|\/)(config|package\.json|tsconfig|next\.config|vite\.config|middleware\.ts)/i.test(file))
456
+ areas.add("config");
457
+ if (/\.(md|mdx)$/i.test(file) || /^docs\//i.test(file))
458
+ areas.add("docs");
459
+ if (/(\.test|\.spec)\.[tj]sx?$|(^|\/)(tests?|__tests__)\//i.test(file))
460
+ areas.add("tests");
461
+ if (/^packages\/cli\//i.test(file))
462
+ areas.add("cli");
463
+ if (/^packages\/core\//i.test(file))
464
+ areas.add("core");
465
+ if (/^(app|pages|components|src\/app|src\/components|styles|public)\//i.test(file))
466
+ areas.add("ui");
467
+ }
468
+ return areas.size > 0 ? [...areas].sort() : ["unknown"];
469
+ }
470
+ export function hashRuntimeFiles(files) {
471
+ return createHash("sha1").update(files.join("\n")).digest("hex").slice(0, 12);
472
+ }
473
+ function normalizeRuntimeState(state) {
474
+ const now = new Date().toISOString();
475
+ return {
476
+ ...state,
477
+ pendingChangedFiles: [...new Set(state.pendingChangedFiles ?? [])].filter((file) => !isIgnoredWatchPath(file)).sort(),
478
+ revision: (state.revision ?? 0) + 1,
479
+ updatedAt: now
480
+ };
481
+ }
482
+ function isRuntimeOlderThanProcessed(runtime, lastProcessedAt) {
483
+ if (!lastProcessedAt || !runtime.lastChangedAt)
484
+ return false;
485
+ return Date.parse(runtime.lastChangedAt) <= Date.parse(lastProcessedAt);
486
+ }
487
+ async function logRuntimeWriteWarning(root, message) {
488
+ await appendTextFile(fromRoot(root, devguardPaths.watchLog), `timestamp=${new Date().toISOString()} ${message}\n`).catch(() => undefined);
489
+ console.warn(`watch warning: ${message}`);
490
+ }
491
+ async function logAtomicWriteWarning(path, message) {
492
+ const devguardDir = dirname(path);
493
+ await appendTextFile(join(devguardDir, "logs", "watch.log"), `timestamp=${new Date().toISOString()} ${message}\n`).catch(() => undefined);
494
+ console.warn(`watch warning: ${message}`);
495
+ }
496
+ function randomSuffix() {
497
+ return Math.random().toString(36).slice(2, 10);
498
+ }
499
+ function sleep(ms) {
500
+ return new Promise((resolve) => setTimeout(resolve, ms));
501
+ }
502
+ function quoteLogValue(value) {
503
+ return JSON.stringify(value);
504
+ }
505
+ function errorMessage(error) {
506
+ return error instanceof Error ? error.message : String(error);
507
+ }
508
+ function normalizeEventPath(root, path) {
509
+ const relativePath = path.startsWith(root) ? relative(root, path) : path;
510
+ return relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
511
+ }
512
+ function isDevguardManagedDocPath(path) {
513
+ return /^(devguard\/)(project|architecture|decisions|tasks)\.md$/.test(path.replace(/\\/g, "/"));
514
+ }
515
+ async function loadChangesWithFallback(root, runtime) {
516
+ try {
517
+ return await getGitChanges(root);
518
+ }
519
+ catch {
520
+ const changeFiles = runtime.pendingChangedFiles.map((path) => ({
521
+ path,
522
+ status: "modified",
523
+ source: "workingTree"
524
+ }));
525
+ return {
526
+ changedFiles: runtime.pendingChangedFiles,
527
+ changeFiles,
528
+ diffText: runtime.pendingChangedFiles.map((path) => `Changed file: ${path}`).join("\n"),
529
+ workingTreeDiffText: "",
530
+ stagedDiffText: ""
531
+ };
532
+ }
533
+ }
534
+ function buildJudgments(input) {
535
+ const judgments = new Set();
536
+ if (input.areas.includes("auth"))
537
+ judgments.add("인증 흐름 변경 가능성 있음");
538
+ if (input.areas.includes("database"))
539
+ judgments.add("데이터 구조 또는 저장 흐름 영향 가능성 있음");
540
+ if (input.areas.includes("api"))
541
+ judgments.add("API 동작 변경 가능성 있음");
542
+ if (input.areas.includes("config"))
543
+ judgments.add("config/runtime 설정 확인 필요");
544
+ if (input.clusters.mixedRisk !== "low")
545
+ judgments.add(`mixed drift risk: ${input.clusters.mixedRisk}`);
546
+ for (const finding of input.checkFindings.slice(0, 3))
547
+ judgments.add(finding);
548
+ if (!input.architectureMarkdown.trim())
549
+ judgments.add("architecture.md가 비어 있어 구조 충돌 검토가 제한됨");
550
+ if (!input.decisionsMarkdown.trim())
551
+ judgments.add("decisions.md가 비어 있어 결정사항 충돌 검토가 제한됨");
552
+ return [...judgments].slice(0, 8);
553
+ }
554
+ function renderLastRunReport(input) {
555
+ return [
556
+ "# dev-guard Last Run",
557
+ "",
558
+ "## 작업 시간",
559
+ `- ${input.timestamp}`,
560
+ "",
561
+ "## 변경 파일",
562
+ ...formatBullets(input.changedFiles),
563
+ "",
564
+ "## 변경 영역 분류",
565
+ ...formatBullets(input.areas),
566
+ "",
567
+ "## Git Diff Stat",
568
+ "```txt",
569
+ input.diffStat.trim() || "No git diff stat available.",
570
+ "```",
571
+ "",
572
+ "## 주요 변경 추정",
573
+ `- ${input.summary}`,
574
+ ...formatBullets(input.majorChanges),
575
+ "",
576
+ "## Drift 후보",
577
+ `- drift: ${input.drift}`,
578
+ ...formatBullets(input.judgments),
579
+ "",
580
+ "## 문서 업데이트 필요 후보",
581
+ `- ${input.updateSummary}`,
582
+ "",
583
+ "## 테스트 필요 후보",
584
+ ...formatBullets(input.testCandidates)
585
+ ].join("\n") + "\n";
586
+ }
587
+ function renderNextPrompt(input) {
588
+ const focusFiles = input.changedFiles.slice(0, 12);
589
+ const recentLines = input.recentHistory
590
+ .slice(-5)
591
+ .reverse()
592
+ .map((record) => `- ${record.timestamp}: ${record.inferredSummary}`);
593
+ return [
594
+ "Before doing any work, read:",
595
+ "",
596
+ `1. \`${devguardPaths.agentContext}\``,
597
+ `2. \`${devguardPaths.projectHandoff}\``,
598
+ "",
599
+ "Use them as the primary source of project context.",
600
+ "Do not perform repository-wide scans before reading them.",
601
+ "",
602
+ "---",
603
+ "",
604
+ "# Codex Handoff Prompt",
605
+ "",
606
+ "아래 인수인계를 기준으로 이어서 작업해줘. 추측보다 파일/명령/검증 결과를 우선하고, 관련 없는 수정은 하지 마.",
607
+ "",
608
+ "## Current Project Context",
609
+ `- project purpose: ${input.projectContext.projectPurpose}`,
610
+ `- current goal: ${input.projectContext.currentGoal}`,
611
+ `- not doing: ${input.projectContext.notDoing}`,
612
+ `- tech stack: ${input.projectContext.techStack}`,
613
+ `- structure: ${input.projectContext.structure}`,
614
+ "- decided:",
615
+ ...formatBullets(input.projectContext.decisions),
616
+ "",
617
+ "## Recent Work Summary",
618
+ "- current done:",
619
+ ` - ${input.summary}`,
620
+ ` - areas: ${input.areas.join(", ")}`,
621
+ ` - drift: ${input.drift}`,
622
+ "- recent history:",
623
+ ...formatBullets(recentLines.map((line) => line.replace(/^- /, ""))),
624
+ "- repeatedly touched areas:",
625
+ ...formatBullets(formatCounts(countItems(input.recentHistory.flatMap((record) => record.areas))).slice(0, 5)),
626
+ "",
627
+ "## Changed Files",
628
+ ...formatBullets(focusFiles.map((file) => `${file} - ${inferFileRole(file)}; area=${classifyAreas([file]).join(",")}`)),
629
+ ...(input.changedFiles.length > focusFiles.length ? [`- ... +${input.changedFiles.length - focusFiles.length} files`] : []),
630
+ "",
631
+ "## Risk / Drift Candidates",
632
+ ...formatRiskDetails(input.riskDetails),
633
+ "",
634
+ "## Quality Gate",
635
+ `- verdict: ${input.qualityReport.verdict}`,
636
+ "- required verification:",
637
+ ...formatBullets(input.qualityReport.requiredVerification),
638
+ "- before commit:",
639
+ ...formatBullets(input.qualityReport.beforeCommit),
640
+ "- blocked/warn items:",
641
+ ...formatBullets(input.qualityReport.checklist
642
+ .filter((item) => item.status !== "PASS")
643
+ .map((item) => `${item.status}: ${item.label} - ${item.detail}`)),
644
+ "",
645
+ "## Do Not Change",
646
+ "- 이번 작업과 관련 없는 영역",
647
+ `- ${devguardPaths.reportsDir}, ${devguardPaths.promptsDir}, ${devguardPaths.runtime} 직접 수정 금지`,
648
+ "- 사용자가 명시하지 않은 대규모 리팩터링 금지",
649
+ "- 기존 공개 명령어 UX 유지: watch/done/status/reset",
650
+ "- 기존 문서 원본 직접 수정 금지. 필요한 내용은 후보 파일 또는 보고로 남길 것",
651
+ "- auth/database/api/config 변경은 현재 작업과 직접 관련 있을 때만 수정",
652
+ "",
653
+ "## Already Decided / Decision Candidates",
654
+ ...formatBullets(input.decisionCandidates.length > 0 ? input.decisionCandidates : ["새 결정 후보 없음"]),
655
+ "",
656
+ "## Next Task",
657
+ `- priority: ${input.nextTask.title}`,
658
+ `- goal: ${input.nextTask.goal}`,
659
+ "- scope:",
660
+ ...formatBullets(input.nextTask.scope),
661
+ "- likely files:",
662
+ ...formatBullets(input.nextTask.likelyFiles),
663
+ "- do not edit:",
664
+ ...formatBullets(input.nextTask.doNotEdit),
665
+ "- success:",
666
+ ...formatBullets(input.nextTask.success),
667
+ "",
668
+ "## Verification Commands",
669
+ ...formatBullets(input.testCommands),
670
+ "",
671
+ "## Completion Report Format",
672
+ "1. 수정한 파일",
673
+ "2. 수행한 작업",
674
+ "3. 수정하지 않은 범위",
675
+ "4. 검증 결과",
676
+ "5. 남은 리스크",
677
+ "6. 다음 권장 작업"
678
+ ].join("\n") + "\n";
679
+ }
680
+ function formatBullets(items) {
681
+ return items.length > 0 ? items.map((item) => `- ${item}`) : ["- none"];
682
+ }
683
+ export async function ensureDevguardDirs(root) {
684
+ await migrateLegacyDevguardDir(root);
685
+ await Promise.all([
686
+ mkdir(dirname(fromRoot(root, reportPath)), { recursive: true }),
687
+ mkdir(dirname(fromRoot(root, promptPath)), { recursive: true }),
688
+ mkdir(fromRoot(root, devguardPaths.contextDir), { recursive: true }),
689
+ mkdir(dirname(fromRoot(root, runtimePath)), { recursive: true }),
690
+ mkdir(dirname(fromRoot(root, historyPath)), { recursive: true })
691
+ ]);
692
+ }
693
+ export async function ensureDevguardWorkspace(root) {
694
+ await ensureDevguardDirs(root);
695
+ await Promise.all([
696
+ writeFileIfMissing(fromRoot(root, devguardPaths.project), projectTemplate()),
697
+ writeFileIfMissing(fromRoot(root, devguardPaths.architecture), architectureTemplate()),
698
+ writeFileIfMissing(fromRoot(root, devguardPaths.decisions), decisionsTemplate()),
699
+ writeFileIfMissing(fromRoot(root, devguardPaths.tasks), tasksTemplate()),
700
+ writeFileIfMissing(fromRoot(root, statePath), "{}\n"),
701
+ writeFileIfMissing(fromRoot(root, historyPath), ""),
702
+ writeFileIfMissing(fromRoot(root, runtimePath), `${JSON.stringify(defaultRuntime, null, 2)}\n`)
703
+ ]);
704
+ }
705
+ async function getGitDiffStat(root) {
706
+ const [workingTree, staged] = await Promise.all([
707
+ execFileAsync("git", ["diff", "--stat"], { cwd: root }).then((result) => result.stdout).catch(() => ""),
708
+ execFileAsync("git", ["diff", "--cached", "--stat"], { cwd: root }).then((result) => result.stdout).catch(() => "")
709
+ ]);
710
+ return [workingTree, staged].filter((text) => text.trim()).join("\n") || "No tracked/staged diff stat.";
711
+ }
712
+ function inferMajorChanges(input) {
713
+ const changes = new Set();
714
+ if (input.areas.includes("ui"))
715
+ changes.add("UI/component surface changed");
716
+ if (input.areas.includes("api"))
717
+ changes.add("API route or server handler changed");
718
+ if (input.areas.includes("config"))
719
+ changes.add("configuration/runtime setting changed");
720
+ if (input.areas.includes("docs"))
721
+ changes.add("documentation changed");
722
+ if (input.diffText.includes("Untracked file:"))
723
+ changes.add("new untracked files are part of the current worktree");
724
+ if (input.changedFiles.some((file) => file.includes("watch")))
725
+ changes.add("watch/event workflow touched");
726
+ if (input.changedFiles.some((file) => file.includes("runtime")))
727
+ changes.add("runtime state handling touched");
728
+ if (input.summary.includes("MIXED:"))
729
+ changes.add("multiple intent clusters detected");
730
+ return [...changes].slice(0, 8);
731
+ }
732
+ async function inferTestCandidates(root, input) {
733
+ const [rootPackage, cliPackage] = await Promise.all([
734
+ readJsonFile(fromRoot(root, "package.json"), {}),
735
+ readJsonFile(fromRoot(root, "packages/cli/package.json"), {})
736
+ ]);
737
+ const rootScripts = rootPackage.scripts ?? {};
738
+ const cliScripts = cliPackage.scripts ?? {};
739
+ const usesPnpm = (rootPackage.packageManager ?? "").startsWith("pnpm") || Object.keys(rootScripts).some((script) => script === "cli");
740
+ const tests = new Set();
741
+ const runner = usesPnpm ? "pnpm" : "npm";
742
+ if (rootScripts.build)
743
+ tests.add(`${runner} run build`);
744
+ if (rootScripts.test)
745
+ tests.add(`${runner} test`);
746
+ if (rootScripts.cli) {
747
+ if (input.changedFiles.some((file) => file.includes("watch")))
748
+ tests.add(`${runner} cli watch --stable-after 1 --compact`);
749
+ if (input.changedFiles.some((file) => file.includes("runtime") || file.includes("index.ts")))
750
+ tests.add(`${runner} cli done`);
751
+ tests.add(`${runner} cli status`);
752
+ if (input.areas.includes("docs"))
753
+ tests.add(`${runner} cli update`);
754
+ }
755
+ else if (cliScripts.cli && usesPnpm) {
756
+ tests.add("pnpm --filter @dev-guard/cli cli status");
757
+ }
758
+ if (tests.size === 0)
759
+ tests.add("확인 필요: package.json scripts에서 검증 명령을 찾지 못함");
760
+ return [...tests];
761
+ }
762
+ async function assessCompletionQuality(root, input) {
763
+ const [rootPackage, cliPackage] = await Promise.all([
764
+ readJsonFile(fromRoot(root, "package.json"), {}),
765
+ readJsonFile(fromRoot(root, "packages/cli/package.json"), {})
766
+ ]);
767
+ const rootScripts = rootPackage.scripts ?? {};
768
+ const checklist = [];
769
+ const rawGeneratedFiles = input.rawChangedFiles.filter(isGeneratedRuntimePath);
770
+ checklist.push({
771
+ label: "generated/runtime files",
772
+ status: rawGeneratedFiles.length > 0 ? "BLOCKED" : "PASS",
773
+ detail: rawGeneratedFiles.length > 0 ? `generated files in git changes: ${rawGeneratedFiles.join(", ")}` : "no generated runtime files in git changes"
774
+ });
775
+ const packageChanged = input.changedFiles.some((file) => /(^|\/)package\.json$/.test(file));
776
+ const lockChanged = input.rawChangedFiles.some((file) => /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/.test(file));
777
+ checklist.push({
778
+ label: "package lock consistency",
779
+ status: packageChanged && !lockChanged ? "BLOCKED" : "PASS",
780
+ detail: packageChanged && !lockChanged ? "package.json changed but no lockfile change detected" : "package/lockfile state does not look inconsistent"
781
+ });
782
+ checklist.push({
783
+ label: "package manifest changed",
784
+ status: packageChanged ? "WARN" : "PASS",
785
+ detail: packageChanged ? "package.json changed; verify scripts/dependencies and publish impact" : "package.json not changed"
786
+ });
787
+ const hasBuildScript = Boolean(rootScripts.build);
788
+ const hasBuildVerification = input.testCandidates.some((command) => /\bbuild\b/.test(command));
789
+ checklist.push({
790
+ label: "build verification candidate",
791
+ status: hasBuildScript && !hasBuildVerification ? "BLOCKED" : "PASS",
792
+ detail: hasBuildScript ? (hasBuildVerification ? "build verification candidate found" : "build script exists but no build command was suggested") : "no build script found"
793
+ });
794
+ checklist.push({
795
+ label: "change breadth",
796
+ status: input.changedFiles.length >= 10 ? "WARN" : "PASS",
797
+ detail: `${input.changedFiles.length} changed file(s)`
798
+ });
799
+ const riskyAreas = input.areas.filter((area) => ["auth", "database", "api", "config"].includes(area));
800
+ checklist.push({
801
+ label: "risky areas",
802
+ status: riskyAreas.length > 0 ? "WARN" : "PASS",
803
+ detail: riskyAreas.length > 0 ? `risky area(s): ${riskyAreas.join(", ")}` : "no auth/database/api/config area detected"
804
+ });
805
+ const commandRouterChanged = input.changedFiles.some((file) => /packages\/cli\/src\/index\.tsx?$/.test(file));
806
+ checklist.push({
807
+ label: "CLI router/help verification",
808
+ status: commandRouterChanged ? "WARN" : "PASS",
809
+ detail: commandRouterChanged ? "CLI command router changed; verify help/status output" : "CLI router not changed"
810
+ });
811
+ const watchChanged = input.changedFiles.some((file) => /watch\.[tj]sx?$/.test(file));
812
+ checklist.push({
813
+ label: "watch verification",
814
+ status: watchChanged ? "WARN" : "PASS",
815
+ detail: watchChanged ? "watch changed; verify --poll and --depth behavior" : "watch implementation not changed"
816
+ });
817
+ const stateHistoryChanged = input.changedFiles.some((file) => /(runtime-state|history|state|prompt)\.[tj]sx?$/.test(file));
818
+ checklist.push({
819
+ label: "state/history verification",
820
+ status: stateHistoryChanged ? "WARN" : "PASS",
821
+ detail: stateHistoryChanged ? "state/history/prompt generation changed; verify done/status output" : "state/history generation not changed"
822
+ });
823
+ const codeChanged = input.changedFiles.some((file) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file));
824
+ const docsChanged = input.changedFiles.some((file) => /\.(md|mdx)$/.test(file) || file.startsWith("docs/"));
825
+ checklist.push({
826
+ label: "docs update candidate",
827
+ status: "PASS",
828
+ detail: codeChanged && !docsChanged ? "source changed without docs changes; recorded as doc update candidate only" : "docs/source balance does not require warning",
829
+ affectsVerdict: false
830
+ });
831
+ const hasDrift = input.judgments.some((item) => /drift|Generated diff/i.test(item));
832
+ checklist.push({
833
+ label: "drift clarity",
834
+ status: hasDrift ? "WARN" : "PASS",
835
+ detail: hasDrift ? `drift candidate present; next task=${input.nextTaskTitle || "missing"}` : "no drift candidate"
836
+ });
837
+ const verdictItems = checklist.filter((item) => item.affectsVerdict !== false);
838
+ const blocked = verdictItems.filter((item) => item.status === "BLOCKED");
839
+ const warns = verdictItems.filter((item) => item.status === "WARN");
840
+ const verdict = blocked.length > 0 ? "BLOCKED" : warns.length > 0 ? "NEEDS_REVIEW" : "PASS";
841
+ const requiredVerification = input.testCandidates.length > 0 ? input.testCandidates : ["확인 필요: package.json scripts에서 검증 명령을 찾지 못함"];
842
+ const beforeCommit = [
843
+ ...requiredVerification.map((command) => `run ${command}`),
844
+ `review ${devguardPaths.qualityReport}`,
845
+ `review ${devguardPaths.nextCodexPrompt}`
846
+ ];
847
+ const nextRecommendedAction = verdict === "BLOCKED"
848
+ ? "fix BLOCKED items before commit"
849
+ : verdict === "NEEDS_REVIEW"
850
+ ? `run ${requiredVerification[0]}, then review ${devguardPaths.qualityReport}`
851
+ : "ready for final review or commit";
852
+ return {
853
+ verdict,
854
+ why: verdict === "PASS"
855
+ ? ["change scope is small, verification exists, and no blocking local quality rule fired"]
856
+ : [...blocked, ...warns].map((item) => `${item.status}: ${item.label} - ${item.detail}`),
857
+ requiredVerification,
858
+ checklist,
859
+ beforeCommit,
860
+ nextRecommendedAction
861
+ };
862
+ }
863
+ function renderQualityReport(report) {
864
+ const blocked = report.checklist.filter((item) => item.status === "BLOCKED");
865
+ const warnings = report.checklist.filter((item) => item.status === "WARN");
866
+ return [
867
+ "# Completion Quality Report",
868
+ "",
869
+ "## Verdict",
870
+ `- ${report.verdict}`,
871
+ "",
872
+ "## Why",
873
+ ...formatBullets(report.why),
874
+ "",
875
+ "## Required Verification",
876
+ ...formatBullets(report.requiredVerification),
877
+ "",
878
+ "## Blocked Items",
879
+ ...formatBullets(blocked.map((item) => `${item.label} - ${item.detail}`)),
880
+ "",
881
+ "## Warnings",
882
+ ...formatBullets(warnings.map((item) => `${item.label} - ${item.detail}`)),
883
+ "",
884
+ "## Risk Checklist",
885
+ ...report.checklist.map((item) => `- ${item.status}: ${item.label} - ${item.detail}`),
886
+ "",
887
+ "## Before Commit",
888
+ ...formatBullets(report.beforeCommit),
889
+ "",
890
+ "## Next Recommended Action",
891
+ `- ${report.nextRecommendedAction}`
892
+ ].join("\n") + "\n";
893
+ }
894
+ function renderProjectHandoff(input) {
895
+ const quality = parseQuality(input.qualityReport.content);
896
+ const nextTask = extractNextTask(input.nextPrompt.content, input.tasks.content, input.state);
897
+ const decisions = importantDecisions(input.decisions.content, input.decisionCandidates.content);
898
+ return [
899
+ "# Project Handoff",
900
+ "",
901
+ "## Current State",
902
+ ...formatBullets(currentStateSummary(input)),
903
+ ...missingInputs([input.project, input.architecture, input.tasks]),
904
+ "",
905
+ "## Active Workflow",
906
+ "- `dev-guard watch` keeps the pending file buffer current.",
907
+ "- Claude/Codex edits files in the normal agent session.",
908
+ "- Trusted Claude Code / Codex Stop Hooks run `dev-guard done` automatically.",
909
+ "- `dev-guard done` writes history, quality-report, next-codex-prompt, and project-handoff.",
910
+ "- Manual fallback: run `dev-guard done`, then `dev-guard handoff` if only the resume file must be refreshed.",
911
+ "- `dev-guard status` shows hook state, quality state, and the handoff path.",
912
+ "",
913
+ "## Recent Changes",
914
+ ...formatBullets(input.records.length > 0
915
+ ? input.records
916
+ .slice(-5)
917
+ .reverse()
918
+ .map((record) => `${record.timestamp}: ${record.inferredSummary}`)
919
+ : extractBullets(input.historySummary.content, 5)),
920
+ ...missingInputs([input.historySummary]),
921
+ "",
922
+ "## Important Decisions",
923
+ ...formatBullets(decisions),
924
+ ...missingInputs([input.decisions, input.decisionCandidates]),
925
+ "",
926
+ "## Quality Status",
927
+ `- verdict: ${quality.verdict}`,
928
+ "- reason:",
929
+ ...formatBullets(quality.why),
930
+ "- required verification:",
931
+ ...formatBullets(quality.requiredVerification),
932
+ ...missingInputs([input.qualityReport]),
933
+ "",
934
+ "## Open Risks",
935
+ ...formatBullets(openRisks(input)),
936
+ "",
937
+ "## Next Best Task",
938
+ `- ${nextTask}`,
939
+ "",
940
+ "## Do Not Change",
941
+ "- Do not add idle-timeout based completion detection.",
942
+ "- Do not add polling-based completion guessing.",
943
+ "- Do not call LLM APIs automatically.",
944
+ "- Do not run git commit automatically.",
945
+ "- Do not change the existing watch/done/status/reset UX.",
946
+ "- Do not change the verified Claude/Codex hook structure unless official docs require it.",
947
+ "",
948
+ "## Resume Prompt",
949
+ ".devguard/reports/project-handoff.md를 읽고 Current State, Quality Status, Next Best Task를 기준으로 이어서 작업해라. 구현되지 않은 기능을 추측하지 말고 현재 파일 기준으로 확인한 뒤 진행해라."
950
+ ].join("\n") + "\n";
951
+ }
952
+ async function readRequiredText(root, path) {
953
+ const content = await readTextFile(fromRoot(root, path));
954
+ const missing = !content.trim();
955
+ return {
956
+ path,
957
+ content: missing ? "확인 필요" : content,
958
+ missing
959
+ };
960
+ }
961
+ function parseHistoryRecords(text) {
962
+ return text
963
+ .split(/\r?\n/)
964
+ .map((line) => line.trim())
965
+ .filter(Boolean)
966
+ .map((line) => {
967
+ try {
968
+ return JSON.parse(line);
969
+ }
970
+ catch {
971
+ return undefined;
972
+ }
973
+ })
974
+ .filter((record) => Boolean(record));
975
+ }
976
+ function parseQuality(markdown) {
977
+ return {
978
+ verdict: firstSectionBullet(markdown, "Verdict") ?? "확인 필요",
979
+ why: extractSectionBullets(markdown, "Why", 4),
980
+ requiredVerification: extractSectionBullets(markdown, "Required Verification", 5)
981
+ };
982
+ }
983
+ function extractNextTask(nextPrompt, tasks, state) {
984
+ const priority = firstSectionBullet(nextPrompt, "Next Task")?.replace(/^priority:\s*/i, "");
985
+ if (priority && priority !== "none")
986
+ return priority;
987
+ const fromTasks = firstSectionBullet(tasks, "다음 작업") ?? firstSectionBullet(tasks, "진행 중");
988
+ if (fromTasks && !/TODO|확인 필요/i.test(fromTasks))
989
+ return fromTasks;
990
+ return summarizeFromState(state, "lastQualityNextAction") ?? "확인 필요";
991
+ }
992
+ function importantDecisions(decisions, candidates) {
993
+ const merged = [...extractDecisionLines(decisions), ...extractSectionBullets(candidates, "기록 후보", 5)];
994
+ return [...new Set(merged.filter(isUsefulText))].slice(0, 6);
995
+ }
996
+ function openRisks(input) {
997
+ const risks = new Set();
998
+ for (const item of [...extractSectionBullets(input.qualityReport.content, "Blocked Items", 5), ...extractSectionBullets(input.qualityReport.content, "Warnings", 5)]) {
999
+ if (item !== "none" && item !== "확인 필요")
1000
+ risks.add(item);
1001
+ }
1002
+ if (/NOT_INSTALLED|unknown|no/i.test(input.hookStatus.content))
1003
+ risks.add("Hook status needs verification in the actual Claude/Codex environment.");
1004
+ if (/Codex CLI: INSTALLED/.test(input.hookStatus.content))
1005
+ risks.add("Codex Stop Hook format is configured; actual Codex runtime trust/execution still needs environment verification.");
1006
+ if (/lastQualityVerdict":\s*"NEEDS_REVIEW"|lastQualityVerdict":\s*"BLOCKED"/.test(input.state)) {
1007
+ risks.add(`Quality state is ${summarizeFromState(input.state, "lastQualityVerdict")}; review quality-report before commit.`);
1008
+ }
1009
+ for (const doc of [input.project, input.architecture, input.decisions, input.tasks, input.historySummary, input.decisionCandidates, input.nextPrompt]) {
1010
+ if (doc.missing)
1011
+ risks.add(`${doc.path} missing or empty; 확인 필요`);
1012
+ }
1013
+ return risks.size > 0 ? [...risks].slice(0, 8) : ["확인 필요: no open risk was identified from current .devguard/ artifacts."];
1014
+ }
1015
+ function currentStateSummary(input) {
1016
+ const summary = new Set();
1017
+ summary.add("watch / done / status / reset workflow is implemented.");
1018
+ summary.add("done writes history, quality-report, next-codex-prompt, and project-handoff.");
1019
+ summary.add("install-hooks writes Claude Code and Codex Stop Hook integration files.");
1020
+ summary.add("Claude Code Stop Hook uses .claude/settings.json.");
1021
+ summary.add("Codex Stop Hook uses .codex/hooks.json; turn.completed is treated as codex exec --json JSONL, not as a hook.");
1022
+ if (/Claude Code: INSTALLED/.test(input.hookStatus.content))
1023
+ summary.add("Claude Code hook status is currently INSTALLED.");
1024
+ if (/Codex CLI: INSTALLED/.test(input.hookStatus.content))
1025
+ summary.add("Codex CLI hook status is currently INSTALLED.");
1026
+ const quality = parseQuality(input.qualityReport.content);
1027
+ if (quality.verdict !== "확인 필요")
1028
+ summary.add(`latest quality verdict is ${quality.verdict}.`);
1029
+ const stateSummary = summarizeFromState(input.state, "lastSummary");
1030
+ if (stateSummary)
1031
+ summary.add(`latest done summary: ${stateSummary}`);
1032
+ for (const item of [summarizeSection(input.project.content, "현재 목표"), summarizeSection(input.architecture.content, "기술 스택"), summarizeSection(input.tasks.content, "진행 중")]) {
1033
+ if (isUsefulText(item))
1034
+ summary.add(item);
1035
+ }
1036
+ return [...summary].slice(0, 10);
1037
+ }
1038
+ function summarizeSection(markdown, heading) {
1039
+ const bullet = firstSectionBullet(markdown, heading);
1040
+ return bullet && !/TODO/i.test(bullet) ? bullet : undefined;
1041
+ }
1042
+ function summarizeFromState(stateJson, key) {
1043
+ try {
1044
+ const state = JSON.parse(stateJson);
1045
+ const value = state[key];
1046
+ return typeof value === "string" && value.trim() ? value : undefined;
1047
+ }
1048
+ catch {
1049
+ return undefined;
1050
+ }
1051
+ }
1052
+ function extractSectionBullets(markdown, heading, limit) {
1053
+ const bullets = sectionLines(markdown, heading)
1054
+ .map((line) => line.trim())
1055
+ .filter((line) => /^[-*]\s+/.test(line))
1056
+ .map((line) => line.replace(/^[-*]\s+/, "").trim())
1057
+ .filter(isUsefulText);
1058
+ return bullets.length > 0 ? bullets.slice(0, limit) : ["확인 필요"];
1059
+ }
1060
+ function extractBullets(markdown, limit) {
1061
+ const bullets = markdown
1062
+ .split(/\r?\n/)
1063
+ .map((line) => line.trim())
1064
+ .filter((line) => /^[-*]\s+/.test(line))
1065
+ .map((line) => line.replace(/^[-*]\s+/, "").trim())
1066
+ .filter(isUsefulText);
1067
+ return bullets.length > 0 ? bullets.slice(0, limit) : ["확인 필요"];
1068
+ }
1069
+ function missingInputs(inputs) {
1070
+ return inputs.filter((input) => input.missing).map((input) => `- ${input.path}: 확인 필요`);
1071
+ }
1072
+ function isUsefulText(value) {
1073
+ return Boolean(value && value.trim() && value.trim() !== "none");
1074
+ }
1075
+ function isGeneratedRuntimePath(file) {
1076
+ return (file === devguardPaths.runtime ||
1077
+ file === devguardPaths.state ||
1078
+ file === devguardPaths.history ||
1079
+ file.startsWith(`${devguardPaths.reportsDir}/`) ||
1080
+ file.startsWith(`${devguardPaths.promptsDir}/`) ||
1081
+ file.startsWith(`${devguardPaths.contextDir}/`) ||
1082
+ file.startsWith(`${devguardPaths.logsDir}/`) ||
1083
+ file.startsWith(`${devguardPaths.hooksDir}/`) ||
1084
+ file.startsWith(".devguard/runs/") ||
1085
+ file === ".devguard/project-index.json" ||
1086
+ file === ".devguard/file-summaries.json" ||
1087
+ file === ".devguard/code-graph.json");
1088
+ }
1089
+ function inferDecisionCandidates(input) {
1090
+ const candidates = new Set();
1091
+ if (input.changedFiles.some((file) => file.includes("watch"))) {
1092
+ candidates.add("watch 자체는 time/idle 기반 완료 추정을 하지 않으며 Auto Mode 완료 처리는 Stop Hook이 담당한다.");
1093
+ candidates.add("watch는 polling fallback과 depth 제한을 지원한다.");
1094
+ }
1095
+ if (input.changedFiles.some((file) => file.includes("runtime-state"))) {
1096
+ candidates.add(".devguard/ runtime 문서는 자동 생성하되 기존 파일은 덮어쓰지 않는다.");
1097
+ candidates.add("done은 last-run뿐 아니라 history/report/prompt 산출물을 함께 생성한다.");
1098
+ }
1099
+ if (input.summary.includes("MIXED:")) {
1100
+ candidates.add("mixed intent는 즉시 실패가 아니라 drift 후보로 기록하고 다음 작업에서 확인한다.");
1101
+ }
1102
+ if (input.areas.includes("config")) {
1103
+ candidates.add("config/runtime 변경은 완료 전 build와 status 확인을 기본 검증으로 둔다.");
1104
+ }
1105
+ for (const judgment of input.judgments) {
1106
+ if (/auth|database|API|config/.test(judgment)) {
1107
+ candidates.add(`${judgment} 변경 시 architecture/decisions 문서 확인이 필요하다.`);
1108
+ }
1109
+ }
1110
+ return [...candidates].slice(0, 8);
1111
+ }
1112
+ function renderHistorySummary(records) {
1113
+ const recent = records.slice(-5).reverse();
1114
+ const areaCounts = countItems(records.flatMap((record) => record.areas));
1115
+ const driftCounts = countItems(records.flatMap((record) => record.driftCandidates.filter((item) => item.toLowerCase().includes("drift"))));
1116
+ const testCounts = countItems(records.flatMap((record) => record.testCandidates));
1117
+ return [
1118
+ "# dev-guard History Summary",
1119
+ "",
1120
+ "## 최근 변경 흐름",
1121
+ ...formatBullets(recent.map((record) => `${record.timestamp} - ${record.inferredSummary}`)),
1122
+ "",
1123
+ "## 반복적으로 수정된 영역",
1124
+ ...formatBullets(formatCounts(areaCounts).slice(0, 8)),
1125
+ "",
1126
+ "## 누적 drift 후보",
1127
+ ...formatBullets(formatCounts(driftCounts).slice(0, 8)),
1128
+ "",
1129
+ "## 아직 테스트가 필요한 영역",
1130
+ ...formatBullets(formatCounts(testCounts).slice(0, 8)),
1131
+ "",
1132
+ "## 다음 작업 전 확인할 점",
1133
+ ...formatBullets(inferNextHistoryChecks(records))
1134
+ ].join("\n") + "\n";
1135
+ }
1136
+ function renderDecisionCandidates(input) {
1137
+ return [
1138
+ "# Decision Candidates",
1139
+ "",
1140
+ `- generatedAt: ${input.timestamp}`,
1141
+ `- source: ${input.summary}`,
1142
+ "",
1143
+ "## 기록 후보",
1144
+ ...formatBullets(input.candidates.length > 0 ? input.candidates : ["이번 done 결과에서 새 결정 후보를 찾지 못함"]),
1145
+ "",
1146
+ "## 근거 파일",
1147
+ ...formatBullets(input.changedFiles.slice(0, 12))
1148
+ ].join("\n") + "\n";
1149
+ }
1150
+ function summarizeProjectContext(input) {
1151
+ return {
1152
+ projectPurpose: firstSectionBullet(input.projectMarkdown, "프로젝트 목적") ?? "확인 필요",
1153
+ currentGoal: firstSectionBullet(input.projectMarkdown, "현재 목표") ?? "확인 필요",
1154
+ notDoing: firstSectionBullet(input.projectMarkdown, "하지 않을 것") ?? "확인 필요",
1155
+ techStack: firstSectionBullet(input.architectureMarkdown, "기술 스택") ?? "확인 필요",
1156
+ structure: firstSectionBullet(input.architectureMarkdown, "주요 디렉토리") ?? "확인 필요",
1157
+ decisions: extractDecisionLines(input.decisionsMarkdown)
1158
+ };
1159
+ }
1160
+ function firstSectionBullet(markdown, heading) {
1161
+ const lines = sectionLines(markdown, heading);
1162
+ const bullet = lines.map((line) => line.trim()).find((line) => /^[-*]\s+/.test(line) && !/TODO|확인 필요/i.test(line));
1163
+ return bullet?.replace(/^[-*]\s+/, "").trim();
1164
+ }
1165
+ function sectionLines(markdown, heading) {
1166
+ const lines = markdown.split(/\r?\n/);
1167
+ const start = lines.findIndex((line) => line.replace(/^#+\s*/, "").trim() === heading);
1168
+ if (start < 0)
1169
+ return [];
1170
+ const result = [];
1171
+ for (const line of lines.slice(start + 1)) {
1172
+ if (/^#{1,3}\s+/.test(line))
1173
+ break;
1174
+ result.push(line);
1175
+ }
1176
+ return result;
1177
+ }
1178
+ function extractDecisionLines(markdown) {
1179
+ const lines = markdown
1180
+ .split(/\r?\n/)
1181
+ .map((line) => line.trim())
1182
+ .filter((line) => line && !/^#|^\| --- |TODO/.test(line) && !/^\|\s*날짜\s*\|/.test(line));
1183
+ const meaningful = lines.filter((line) => /\|/.test(line) || /^[-*]\s+/.test(line)).slice(0, 5);
1184
+ return meaningful.length > 0 ? meaningful.map((line) => line.replace(/^[-*]\s+/, "")) : ["확인 필요"];
1185
+ }
1186
+ function inferFileRole(file) {
1187
+ if (file.endsWith("package.json"))
1188
+ return "script/dependency config";
1189
+ if (file.endsWith("pnpm-lock.yaml") || file.endsWith("package-lock.json") || file.endsWith("yarn.lock"))
1190
+ return "lockfile/dependency snapshot";
1191
+ if (file === ".gitignore")
1192
+ return "generated artifacts exclusion";
1193
+ if (/src\/index\.tsx?$/.test(file) || /src\/index\.jsx?$/.test(file))
1194
+ return "CLI command router / entrypoint";
1195
+ if (/watch\.[tj]sx?$/.test(file))
1196
+ return "watch command / file watcher";
1197
+ if (/runtime-state\.[tj]sx?$/.test(file))
1198
+ return "runtime state / history persistence";
1199
+ if (/review\.[tj]sx?$/.test(file))
1200
+ return "review command / drift analysis";
1201
+ if (/update\.[tj]sx?$/.test(file))
1202
+ return "docs update preview/write logic";
1203
+ if (/\.mdx?$/.test(file))
1204
+ return "documentation";
1205
+ if (/(\.test|\.spec)\.[tj]sx?$/.test(file))
1206
+ return "test file";
1207
+ if (/app\/api|pages\/api|route\.[tj]s$/.test(file))
1208
+ return "API/server route";
1209
+ if (/components|app|pages/.test(file))
1210
+ return "UI/component surface";
1211
+ if (/config|tsconfig|vite|next\.config/.test(file))
1212
+ return "build/runtime config";
1213
+ return "source file";
1214
+ }
1215
+ function buildRiskDetails(input) {
1216
+ const relatedFiles = input.changedFiles.slice(0, 6);
1217
+ const risks = input.judgments.slice(0, 5).map((judgment) => ({
1218
+ content: judgment,
1219
+ relatedFiles,
1220
+ reason: riskReason(judgment, input.areas),
1221
+ checkMethod: riskCheckMethod(judgment, input.areas),
1222
+ decisionRule: riskDecisionRule(judgment)
1223
+ }));
1224
+ return risks.length > 0
1225
+ ? risks
1226
+ : [
1227
+ {
1228
+ content: "명시적 drift 후보 없음",
1229
+ relatedFiles,
1230
+ reason: "local heuristic에서 blocking 후보를 찾지 못함",
1231
+ checkMethod: "변경 파일을 직접 확인하고 검증 명령 실행",
1232
+ decisionRule: "검증 통과 시 추가 수정 없이 종료"
1233
+ }
1234
+ ];
1235
+ }
1236
+ function riskReason(judgment, areas) {
1237
+ if (/mixed|drift/i.test(judgment))
1238
+ return "여러 변경 의도가 섞였거나 현재 task와 다른 방향일 수 있음";
1239
+ if (/auth|database|API|config/.test(judgment))
1240
+ return `${areas.join(", ")} 영역은 런타임 동작 영향이 클 수 있음`;
1241
+ if (/docs|문서/i.test(judgment))
1242
+ return "코드 변경과 문서 상태가 어긋날 수 있음";
1243
+ return "rule-based check에서 확인 후보로 분류됨";
1244
+ }
1245
+ function riskCheckMethod(judgment, areas) {
1246
+ if (/mixed|drift/i.test(judgment))
1247
+ return "변경 파일이 하나의 작업 목표로 설명되는지 확인";
1248
+ if (areas.includes("config"))
1249
+ return "빌드와 CLI status를 실행해 설정 영향 확인";
1250
+ if (areas.includes("api"))
1251
+ return "API route 변경 diff와 호출 파일 확인";
1252
+ return "관련 파일 diff를 읽고 package scripts 기반 검증 명령 실행";
1253
+ }
1254
+ function riskDecisionRule(judgment) {
1255
+ if (/high|drift/i.test(judgment))
1256
+ return "현재 작업 목표와 직접 관련 없으면 수정/분리 후보";
1257
+ if (/docs|문서/i.test(judgment))
1258
+ return "코드 동작 변경이면 update 후보 생성, 직접 원본 문서 수정 금지";
1259
+ return "검증 명령 통과와 관련 파일 일치 여부로 판단";
1260
+ }
1261
+ function formatRiskDetails(details) {
1262
+ return details.flatMap((detail, index) => [
1263
+ `- candidate ${index + 1}: ${detail.content}`,
1264
+ ` - related files: ${detail.relatedFiles.length > 0 ? detail.relatedFiles.join(", ") : "none"}`,
1265
+ ` - why check: ${detail.reason}`,
1266
+ ` - how to check: ${detail.checkMethod}`,
1267
+ ` - decision rule: ${detail.decisionRule}`
1268
+ ]);
1269
+ }
1270
+ function chooseNextTask(input) {
1271
+ if (input.changedFiles.some((file) => file.includes("watch")) && input.judgments.some((item) => /EMFILE|watch|mixed/i.test(item))) {
1272
+ return nextTask("watch stability verification", "watch가 안정적으로 변경을 감지하고 EMFILE fallback 안내를 유지하는지 확인한다.", input);
1273
+ }
1274
+ if (input.changedFiles.some((file) => file.includes("runtime-state") || file.includes("prompt")) || input.judgments.some((item) => /prompt|history/i.test(item))) {
1275
+ return nextTask("handoff prompt quality", "next-codex-prompt.md가 다음 에이전트가 바로 작업할 수 있는 인수인계 문서인지 확인한다.", input);
1276
+ }
1277
+ if (input.testCandidates.length > 0 && input.drift !== "low") {
1278
+ return nextTask("verification before commit", "현재 변경을 추가 수정하기 전에 검증 명령을 실행하고 drift 후보를 정리한다.", input);
1279
+ }
1280
+ if (input.judgments.some((item) => /docs|문서/i.test(item))) {
1281
+ return nextTask("docs update candidate review", "문서 원본을 직접 수정하지 않고 update 후보가 필요한지 확인한다.", input);
1282
+ }
1283
+ return nextTask("final review", "변경 파일을 확인하고 빌드/status 결과 기준으로 커밋 가능 여부를 판단한다.", input);
1284
+ }
1285
+ function nextTask(title, goal, input) {
1286
+ const likelyFiles = input.changedFiles.slice(0, 8);
1287
+ return {
1288
+ title,
1289
+ goal,
1290
+ scope: [`areas: ${input.areas.join(", ")}`, "현재 changed files 안에서만 최소 수정"],
1291
+ likelyFiles: likelyFiles.length > 0 ? likelyFiles : ["확인 필요"],
1292
+ doNotEdit: [`${devguardPaths.reportsDir}/*`, `${devguardPaths.promptsDir}/*`, devguardPaths.runtime, "관련 없는 product/source files"],
1293
+ success: ["검증 명령 통과", "drift 후보가 설명되거나 해소됨", "next-codex-prompt가 현재 변경과 일치"]
1294
+ };
1295
+ }
1296
+ function countItems(items) {
1297
+ const counts = new Map();
1298
+ for (const item of items.filter(Boolean)) {
1299
+ counts.set(item, (counts.get(item) ?? 0) + 1);
1300
+ }
1301
+ return counts;
1302
+ }
1303
+ function formatCounts(counts) {
1304
+ return [...counts.entries()]
1305
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1306
+ .map(([item, count]) => `${item} (${count})`);
1307
+ }
1308
+ function inferNextHistoryChecks(records) {
1309
+ const recent = records.slice(-5);
1310
+ const checks = new Set();
1311
+ if (recent.some((record) => record.areas.includes("auth") || record.areas.includes("database") || record.areas.includes("api"))) {
1312
+ checks.add("auth/database/api 관련 변경은 architecture.md와 decisions.md 반영 여부 확인");
1313
+ }
1314
+ if (recent.some((record) => record.inferredSummary.includes("MIXED:"))) {
1315
+ checks.add("mixed intent가 누적되어 다음 작업 전 변경 범위 분리 여부 확인");
1316
+ }
1317
+ if (recent.some((record) => record.testCandidates.length > 0)) {
1318
+ checks.add("최근 작업의 테스트 후보 명령 실행 여부 확인");
1319
+ }
1320
+ checks.add(`다음 Codex 작업 전 ${devguardPaths.nextCodexPrompt} 확인`);
1321
+ return [...checks];
1322
+ }
1323
+ function projectTemplate() {
1324
+ return [
1325
+ "# Project",
1326
+ "",
1327
+ "## 프로젝트 목적",
1328
+ "- TODO",
1329
+ "",
1330
+ "## 핵심 사용자",
1331
+ "- TODO",
1332
+ "",
1333
+ "## 현재 목표",
1334
+ "- TODO",
1335
+ "",
1336
+ "## 하지 않을 것",
1337
+ "- TODO"
1338
+ ].join("\n") + "\n";
1339
+ }
1340
+ function architectureTemplate() {
1341
+ return [
1342
+ "# Architecture",
1343
+ "",
1344
+ "## 기술 스택",
1345
+ "- TODO",
1346
+ "",
1347
+ "## 주요 디렉토리",
1348
+ "- TODO",
1349
+ "",
1350
+ "## 인증/DB/API 구조",
1351
+ "- TODO",
1352
+ "",
1353
+ "## 외부 서비스",
1354
+ "- TODO"
1355
+ ].join("\n") + "\n";
1356
+ }
1357
+ function decisionsTemplate() {
1358
+ return [
1359
+ "# Decisions",
1360
+ "",
1361
+ "| 날짜 | 결정 | 이유 | 영향 |",
1362
+ "| --- | --- | --- | --- |",
1363
+ "| TODO | TODO | TODO | TODO |"
1364
+ ].join("\n") + "\n";
1365
+ }
1366
+ function tasksTemplate() {
1367
+ return [
1368
+ "# Tasks",
1369
+ "",
1370
+ "## 진행 중",
1371
+ "- TODO",
1372
+ "",
1373
+ "## 다음 작업",
1374
+ "- TODO",
1375
+ "",
1376
+ "## 보류",
1377
+ "- TODO",
1378
+ "",
1379
+ "## 완료",
1380
+ "- TODO"
1381
+ ].join("\n") + "\n";
1382
+ }
1383
+ //# sourceMappingURL=runtime-state.js.map