projectops 4.6.0 → 4.7.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.5.0 (2026-09-16)
10
+ ## 최신 버전 : v4.6.1 (2026-09-16)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
package/src/cli/args.js CHANGED
@@ -17,6 +17,7 @@ export function parseArgs(argv) {
17
17
  deployBranch: "", // 릴리스 PR head 브랜치 (#456): --deploy-branch, 빈 값=미지정
18
18
  intent: null, // 프로젝트 성격 (#485): --intent app|library|both|none|manual, null=미설정(역추론)
19
19
  includeSecretBackup: null,
20
+ aiPrSummary: null, // #566 — AI 변경 요약 워크플로우 포함 여부
20
21
  pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
21
22
  force: false,
22
23
  help: false,
@@ -101,6 +102,9 @@ export function parseArgs(argv) {
101
102
  break;
102
103
  case "--secret-backup": result.includeSecretBackup = true; break;
103
104
  case "--no-secret-backup": result.includeSecretBackup = false; break;
105
+ // #566 — 비대화형에서도 켜고 끌 수 있어야 한다. 없으면 자동화 환경은 선택권이 없다.
106
+ case "--ai-summary": result.aiPrSummary = true; break;
107
+ case "--no-ai-summary": result.aiPrSummary = false; break;
104
108
  case "--npm-publish":
105
109
  process.stderr.write("⚠️ --npm-publish는 deprecated입니다. --publish npm 을 사용하세요.\n");
106
110
  result.publishTargets = [...new Set([...(result.publishTargets ?? []), "npm"])];
package/src/cli/help.js CHANGED
@@ -20,6 +20,7 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
20
20
  --publish CSV publish 타겟 csv: nexus,npm,github-packages (기본: 없음)
21
21
  --deploy-branch NAME 릴리스 PR head 브랜치 (#456, 기본: develop). default_branch와 별개
22
22
  --secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
23
+ --ai-summary / --no-ai-summary PR 변경 요약 워크플로우 포함/제외
23
24
  --nexus / --npm-publish (deprecated — --publish nexus / --publish npm 사용)
24
25
  --force 모든 확인 생략, 비대화형 기본값 사용
25
26
  -v, --version projectops 버전 출력
@@ -23,8 +23,8 @@ import { verifyInstall } from "../core/verify.js";
23
23
  export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
24
24
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
25
25
  force = true, now, today, templateVersion = "unknown",
26
- deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
27
- changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
26
+ deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true,
27
+ changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
28
28
  deployBranch = "", intent = null, semverAuto = true , appRelease = null } = context;
29
29
 
30
30
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
@@ -43,7 +43,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
43
43
  buildVersionYml({
44
44
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
45
45
  deployValues,
46
- templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
46
+ templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, aiPrSummary, optionsDate: today,
47
47
  changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease },
48
48
  })), { version, versionCode });
49
49
 
@@ -13,7 +13,7 @@ import { runBreakingCheck } from "../core/breaking-check.js";
13
13
  import { runMigrations } from "../core/migrations/index.js";
14
14
  import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
15
15
  import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
16
- import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets } from "../core/options-ask.js";
16
+ import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets, migrateProvider } from "../core/options-ask.js";
17
17
  import { createRunTrace, MIGRATION_DIR } from "../core/run-trace.js";
18
18
  import { appendGuideEntry } from "../core/migration-guide.js";
19
19
  import { promptEnvPlan } from "../ui/env-plan.js";
@@ -41,6 +41,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
41
41
  // finally에서 기록할 때 쓰는 값 — try 안에서 확정되기 전에 취소될 수 있으므로 바깥에 둔다 (#561).
42
42
  let traceFrom = "";
43
43
  let traceTo = "";
44
+ // 기록 파일을 남기는 모드인지 — 모드 선택 전에 취소될 수 있어 기본값은 false.
45
+ let recordArtifacts = false;
46
+ // 기록을 남기지 않고 끝낼 경우(version/issues 모드의 정상 완주)에만 true.
47
+ // 취소·예외는 모드와 무관하게 남긴다 — 끊겼을 때가 "어디까지 갔는지"가 가장 궁금한 순간이다.
48
+ let skipRecord = false;
44
49
  if (io === prompts) trace.mirrorStart();
45
50
  try {
46
51
  // 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
@@ -113,8 +118,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
113
118
  let deployTarget = existing?.options?.deploy ?? "docker-ssh";
114
119
  let publishTargets = existing?.options?.publish ?? [];
115
120
  let includeSecretBackup = existing?.options?.secretBackup ?? false;
121
+ let aiPrSummary = existing?.options?.aiPrSummary ?? null; // null = 아직 안 물음 (#566)
116
122
  let codeReviewCoderabbit = existing?.options?.codeReviewCoderabbit ?? true;
117
- let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
123
+ let changelogProvider = migrateProvider(existing?.options?.changelogProvider) ?? "commit";
118
124
  let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
119
125
  let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
120
126
  let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
@@ -341,7 +347,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
341
347
  // 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
342
348
  const orphanReport = { cleaned: [], pending: [] }; // #493 — 가이드 기록용
343
349
  if (mode === "full" || mode === "workflows") {
344
- const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
350
+ const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types,
351
+ // 껐는데 남아 계속 도는 워크플로우도 함께 잡는다 (#566)
352
+ options: { includeSecretBackup, aiPrSummary: aiPrSummary !== false, deployTarget } });
345
353
  if (orphans.length > 0) {
346
354
  io.note?.(
347
355
  orphans.map((o) => `• ${o.filename} (${o.type} 타입 — 현재 미선택)`).join("\n"),
@@ -378,7 +386,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
378
386
 
379
387
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
380
388
  let migrationGuidePath = null;
381
- const recordArtifacts = mode === "full" || mode === "workflows";
389
+ recordArtifacts = mode === "full" || mode === "workflows";
382
390
  // 경로는 먼저 계산하고 실제 쓰기는 완료 화면 뒤로 미룬다 — 요약까지 터미널 미러에 담기 위함 (#561)
383
391
  const files = recordArtifacts
384
392
  ? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now })
@@ -406,16 +414,12 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
406
414
  }, cwd);
407
415
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
408
416
 
409
- // 완료 화면까지 캡처한종료하고 기록한다 (#561)
417
+ // 완료 화면까지 출력한finally에서 닫는다 (#561) — 여기서 닫으면 이 아래 화면이 안 담긴다.
410
418
  trace.event("run", "end", mode || "", {
411
419
  workflowsCopied: result?.workflows?.copied ?? 0,
412
420
  workflowsSkipped: result?.workflows?.skipped ?? 0,
413
421
  });
414
- if (recordArtifacts) {
415
- trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now });
416
- } else {
417
- trace.mirrorStop();
418
- }
422
+ skipRecord = !recordArtifacts;
419
423
  return 0;
420
424
  } catch (err) {
421
425
  trace.event("run", "error", "interactive", {
@@ -424,9 +428,10 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
424
428
  });
425
429
  throw err;
426
430
  } finally {
427
- // 어떤 경로로 빠져나가든 기록을 남긴다 (#561) — 중간 취소·예외 포함.
428
- // finalize는 멱등이라 정상 경로에서 이미 호출됐으면 여기서는 아무 일도 하지 않는다.
429
- trace.finalize({ targetRoot: cwd, fromVersion: traceFrom, toVersion: traceTo, now: clock?.now || "" });
431
+ // 어떤 경로로 빠져나가든 기록을 남긴다 (#561) — 중간 취소·예외·Ctrl+C 포함.
432
+ // 완료 화면 출력이 try 안에 있어야 화면까지 미러에 담긴 채로 여기서 닫힌다.
433
+ if (skipRecord) trace.mirrorStop();
434
+ else trace.finalize({ targetRoot: cwd, fromVersion: traceFrom, toVersion: traceTo, now: clock?.now || "" });
430
435
  disarmSignals();
431
436
  remove(tempDir);
432
437
  }
@@ -443,7 +448,7 @@ function summarize({ mode, types, version, branch, deployTarget, publishTargets,
443
448
  lines.push(`배포 방식 : ${deployTarget || "docker-ssh"}`);
444
449
  lines.push(`Publish : ${(publishTargets ?? []).join(",") || "없음"}`);
445
450
  lines.push(`Secret 백업 : ${includeSecretBackup ? "포함" : "제외"}`);
446
- lines.push(`Changelog : ${changelogProvider || "github-ai"}`);
451
+ lines.push(`Changelog : ${changelogProvider || "commit"}`);
447
452
  lines.push(`CodeRabbit 리뷰 : ${codeReviewCoderabbit ? "사용" : "미사용"}`);
448
453
  }
449
454
  return lines.join("\n");
@@ -13,7 +13,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
13
13
  export function runVersion(context, tempDir, targetRoot = ".") {
14
14
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
15
15
  now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
16
- changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
16
+ changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
17
17
  deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null } = context;
18
18
 
19
19
  const pathMarkers = new Map();
package/src/context.js CHANGED
@@ -19,8 +19,9 @@ export function createContext(overrides = {}) {
19
19
  deployTarget: null, // 'docker-ssh'(기본) | 'vercel' | 'none'
20
20
  publishTargets: null, // ['nexus','npm','github-packages'] 부분집합
21
21
  includeSecretBackup: null,
22
+ aiPrSummary: null, // #566 — AI 변경 요약 워크플로우 포함 여부
22
23
  // changelog provider 축 (#455 — null=미설정)
23
- changelogProvider: null, // 'github-ai'(기본) | 'coderabbit' | 'openai' | 'gemini' | 'claude' | 'ollama' | 'commit'
24
+ changelogProvider: null, // 'commit'(기본) | 'coderabbit' | 'openai' | 'gemini' | 'claude' | 'ollama' | 'commit'
24
25
  changelogBaseUrl: null, // ollama일 때만 값
25
26
  codeReviewCoderabbit: null,
26
27
  deployBranch: "", // 릴리스 PR head 브랜치 (#456). 빈 값=metadata.deploy_branch 미출력
@@ -20,8 +20,10 @@ export function copyScripts(tempDir, targetRoot = ".") {
20
20
  "dispatch_downstream.py",
21
21
  // AI PR SUMMARY 워크플로우가 요약 댓글을 작성·갱신할 때 호출 (#553).
22
22
  "pr_summary_comment.py",
23
+ // 릴리스 노트가 어떤 경로로 만들어졌는지 알리고, AI를 못 썼을 때 대안을 안내 (#566).
24
+ "changelog_notice.py",
23
25
  "changelog_providers/_common.py", "changelog_providers/ladder.py",
24
- "changelog_providers/commit.py", "changelog_providers/github_ai.py",
26
+ "changelog_providers/commit.py", "changelog_providers/copilot.py",
25
27
  "changelog_providers/openai_compatible.py",
26
28
  ];
27
29
  let copied = 0;
@@ -63,14 +63,14 @@ function classify(srcDir, workflowsDir, envOpts, baseline = null) {
63
63
  }
64
64
 
65
65
  // copy_workflows 본체 (동기 — 기존 호출부 무변경).
66
- // context: { types:[], paths:Map, deployTarget, publishTargets:[], includeSecretBackup, force, repoName, resolvers,
66
+ // context: { types:[], paths:Map, deployTarget, publishTargets:[], includeSecretBackup, aiPrSummary, force, repoName, resolvers,
67
67
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
68
68
  // deployTarget(#439 택1): 'docker-ssh'(기본) | 'vercel' | 'none' — server-deploy는 docker-ssh일 때만,
69
69
  // common/deploy/<target>/은 해당 타겟일 때 복사. publishTargets(#439 다중): 'nexus'|'npm'|'github-packages'.
70
70
  // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
71
71
  // 반환: {copied, skipped, templateAdded, optionalCopied, copiedFiles[]} — copiedFiles는 실제 복사·교체된 파일명 (#473 요약용)
72
72
  export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
73
- const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
73
+ const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
74
74
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
75
75
  const trace = hooks.trace ?? null; // #494 — 실행 트레이스 (null-safe: 미주입이면 전 이벤트 no-op)
76
76
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
@@ -164,6 +164,29 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
164
164
  }
165
165
  }
166
166
 
167
+ // (4.7) common/pr-summary — AI 변경 요약 (#566). 선택했을 때만 복사한다.
168
+ // 종전에는 common 본체에 있어 무조건 복사된 뒤 런타임에 스스로 빠졌고, 그 판단이
169
+ // ".coderabbit.yaml 존재"라 파일만 있고 앱이 없는 저장소에서는 아무도 요약하지 않았다.
170
+ const prSummaryDir = join(commonDir, "pr-summary");
171
+ if (exists(prSummaryDir) && aiPrSummary) {
172
+ for (const filename of listYamlFiles(prSummaryDir)) {
173
+ const src = join(prSummaryDir, filename);
174
+ const dst = join(workflowsDir, filename);
175
+ if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
176
+ counters.skipped++;
177
+ trace?.event("copy", "skipped-unchanged", filename, { group: "pr-summary" });
178
+ continue;
179
+ }
180
+ const backedUp = existsSync(dst);
181
+ if (backedUp) renameSync(dst, dst + ".bak");
182
+ copyFileSync(src, dst);
183
+ counters.optionalCopied++;
184
+ counters.copied++;
185
+ counters.copiedFiles.push(filename);
186
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "pr-summary" });
187
+ }
188
+ }
189
+
167
190
  // (5) common/secret-backup — 있으면 무조건 스킵/신규만 복사
168
191
  const secretDir = join(commonDir, "secret-backup");
169
192
  if (exists(secretDir) && includeSecretBackup) {
@@ -92,8 +92,19 @@ export function applicableTargets(types = []) {
92
92
  publish: PUBLISH_TARGETS.filter((t) => types.some((ty) => (TYPE_PUBLISH_TARGETS[ty] ?? PUBLISH_TARGETS).includes(t))),
93
93
  };
94
94
  }
95
- // #455 — changelog 생성기 provider. github-ai가 기본(설정 제로). openai/gemini/claude/ollama는 openai 호환 한 갈래.
96
- export const CHANGELOG_PROVIDERS = ["github-ai", "coderabbit", "openai", "gemini", "claude", "ollama", "commit"];
95
+ // changelog 생성기 provider (#455, #566에서 재정비).
96
+ // 기본값은 commit 외부 의존이 없어 어떤 환경에서도 결과가 나온다. AI를 쓰려면
97
+ // MODEL_API_KEY를 등록하면 사다리가 자동으로 집어 쓴다(마법사는 묻지 않는다).
98
+ // github-ai는 GitHub Models 종료(2026-07-30)로 제외됐다.
99
+ export const CHANGELOG_PROVIDERS = ["copilot", "coderabbit", "openai", "gemini", "claude", "groq", "mistral", "ollama", "commit"];
100
+
101
+ // 더 이상 동작하지 않는 저장값을 살아있는 값으로 옮긴다 (#566).
102
+ // 기존 저장소가 업데이트만 돌려도 죽은 설정에서 벗어나게 하는 유일한 경로다.
103
+ const RETIRED_PROVIDERS = { "github-ai": "commit" };
104
+ export function migrateProvider(saved) {
105
+ if (saved == null) return null;
106
+ return RETIRED_PROVIDERS[saved] ?? saved;
107
+ }
97
108
 
98
109
  const isCancel = (v) => typeof v === "symbol";
99
110
 
@@ -140,7 +151,7 @@ const INTENT_ASKS_PUBLISH = { library: true, both: true, manual: true, app: fals
140
151
  // current: { deploy: string|null, publish: string[]|null, secretBackup: bool|null } — CLI 명시값
141
152
  // scope: null이면 전 축(초기 통합·전체 재질문). Set/배열이면 그 축만 forceAsk 대상 (#483 수정 메뉴 격리).
142
153
  // 스코프 밖 축은 forceAsk여도 current/저장값을 그대로 유지하고 다시 묻지 않는다.
143
- // 반환: { deploy, publish, secretBackup, codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch }
154
+ // 반환: { deploy, publish, secretBackup, codeReviewCoderabbit, aiPrSummary, changelogProvider, changelogBaseUrl, deployBranch }
144
155
  export async function askAllOptionalWorkflows({
145
156
  tempDir, types = [], current = {}, targetRoot = ".",
146
157
  force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "", scope = null,
@@ -153,6 +164,7 @@ export async function askAllOptionalWorkflows({
153
164
  let publish = current.publish ?? null;
154
165
  let secretBackup = current.secretBackup ?? null;
155
166
  let codeReviewCoderabbit = current.codeReviewCoderabbit ?? null;
167
+ let aiPrSummary = current.aiPrSummary ?? null;
156
168
  let changelogProvider = current.changelogProvider ?? null;
157
169
  let changelogBaseUrl = current.changelogBaseUrl ?? null;
158
170
  let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
@@ -187,6 +199,7 @@ export async function askAllOptionalWorkflows({
187
199
  }
188
200
  // #455 changelog/code_review 저장값 재사용
189
201
  if (codeReviewCoderabbit === null && saved.codeReviewCoderabbit !== null) codeReviewCoderabbit = saved.codeReviewCoderabbit;
202
+ if (aiPrSummary === null && saved.aiPrSummary != null) aiPrSummary = saved.aiPrSummary;
190
203
  if (changelogProvider === null && saved.changelogProvider !== null) changelogProvider = saved.changelogProvider;
191
204
  if (changelogBaseUrl === null && saved.changelogBaseUrl !== null) changelogBaseUrl = saved.changelogBaseUrl;
192
205
  // #456 deploy_branch 저장값 재사용
@@ -313,19 +326,36 @@ export async function askAllOptionalWorkflows({
313
326
  }
314
327
  }
315
328
 
316
- // ── code_review: CodeRabbit AI 코드 리뷰 (changelog와 무관 — #455) ──
317
- if (ask("code-review") || codeReviewCoderabbit === null) {
318
- if (force || !tty || typeof io.confirm !== "function") {
329
+ // ── pr_comment: PR에 리뷰·요약 (#566에서 통합) ──
330
+ // 종전에는 CodeRabbit 여부만 예/아니오로 물었고, AI 변경 요약은 물어보지도 않은 채
331
+ // 항상 복사된 런타임에 스스로 빠졌다. 결과 "CodeRabbit도 안 달고 요약도 안 다는"
332
+ // 저장소가 생겼다. 둘은 같은 자리(PR 댓글)를 놓고 경쟁하므로 한 번에 고르게 한다.
333
+ if (ask("code-review") || codeReviewCoderabbit === null || aiPrSummary === null) {
334
+ if (force || !tty || typeof io.select !== "function") {
335
+ // 비대화형 기본값: CodeRabbit은 외부 앱 설치가 필요하므로 끄고, 자체 요약만 켠다.
319
336
  codeReviewCoderabbit = codeReviewCoderabbit ?? false;
337
+ aiPrSummary = aiPrSummary ?? true;
320
338
  } else {
321
339
  say("");
322
- say("🤖 CodeRabbit AI 코드 리뷰를 쓸까요? (PR 올릴 코드 리뷰 댓글을 답니다)");
323
- const ans = await io.confirm({ message: "CodeRabbit AI 코드 리뷰 사용", initialValue: false });
324
- codeReviewCoderabbit = (ans === true && !isCancel(ans));
325
- say(`CodeRabbit 코드 리뷰: ${codeReviewCoderabbit ? "사용" : "미사용"}`);
340
+ say("💬 PR 리뷰·요약 댓글을 어떻게 받으시겠어요?");
341
+ const ans = await io.select({
342
+ message: "PR 댓글 방식을 선택하세요",
343
+ options: [
344
+ { value: "summary", label: "AI 변경 요약 (추천 · 추가 설정 불필요)" },
345
+ { value: "coderabbit", label: "CodeRabbit 코드 리뷰 (외부 앱 설치 필요)" },
346
+ { value: "both", label: "둘 다" },
347
+ { value: "none", label: "사용 안 함" },
348
+ ],
349
+ });
350
+ // 취소·미응답은 추천값으로 — 다른 축과 같은 방어 패턴이다.
351
+ const PR_COMMENT_CHOICES = ["summary", "coderabbit", "both", "none"];
352
+ const pick = (!isCancel(ans) && PR_COMMENT_CHOICES.includes(ans)) ? ans : "summary";
353
+ codeReviewCoderabbit = pick === "coderabbit" || pick === "both";
354
+ aiPrSummary = pick === "summary" || pick === "both";
355
+ say(`PR 댓글: ${{ summary: "AI 변경 요약", coderabbit: "CodeRabbit", both: "둘 다", none: "사용 안 함" }[pick]}`);
326
356
  // #481 — "사용"만으로는 안 붙는다. 앱 설치 + 레포 접근 권한이 있어야 실제로 리뷰가 달린다.
327
357
  if (codeReviewCoderabbit) {
328
- say(" ⚠️ 실제로 리뷰가 붙으려면 추가 설정이 필요합니다:");
358
+ say(" ⚠️ CodeRabbit은 추가 설정이 필요합니다:");
329
359
  say(" 1) https://coderabbit.ai 접속 → GitHub으로 로그인");
330
360
  say(" 2) CodeRabbit GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
331
361
  say(" (이 단계를 안 하면 워크플로우는 켜져도 PR에 리뷰 댓글이 달리지 않습니다)");
@@ -333,29 +363,12 @@ export async function askAllOptionalWorkflows({
333
363
  }
334
364
  }
335
365
 
336
- // ── changelog: 릴리스 노트 생성기 (기본 커서 = github-ai — #455) ──
337
- if (ask("changelog") || changelogProvider === null) {
338
- if (force || !tty || typeof io.select !== "function") {
339
- changelogProvider = changelogProvider ?? "github-ai";
340
- } else {
341
- say("");
342
- say("📝 릴리스 노트(changelog)는 1순위로 뭘로 만들까요?");
343
- say(" GitHub AI는 설정 없이 바로 됩니다. 나머지는 나중에 GitHub Secret 등록이 필요할 수 있어요.");
344
- // #481 — 하나만 골라야 하는 게 아니다. 고른 게 실패하면 자동 폴백하므로 안심하고 고르라고 안내.
345
- say(" ✅ 고른 방식이 실패해도 자동으로 GitHub AI → 커밋 분석 순으로 폴백하니 릴리스 노트는 항상 생성됩니다.");
346
- const ans = await io.select({
347
- message: "1순위 changelog 생성기를 선택하세요 (실패 시 자동 폴백)",
348
- options: [
349
- { value: "github-ai", label: "GitHub AI (추천 · 설정 불필요)" },
350
- { value: "coderabbit", label: "CodeRabbit" },
351
- { value: "openai", label: "OpenAI 호환 API (키 등록 필요)" },
352
- { value: "commit", label: "커밋 분석만 (AI 없음 · 최후 안전망)" },
353
- ],
354
- });
355
- changelogProvider = (!isCancel(ans) && CHANGELOG_PROVIDERS.includes(ans)) ? ans : (changelogProvider ?? "github-ai");
356
- say(`changelog 생성기: ${changelogProvider}`);
357
- }
358
- }
366
+ // ── changelog: 생성기를 묻지 않는다 (#566) ──
367
+ // 사용자의 목적은 "릴리스 노트가 잘 나오는 것"이지 provider 선택이 아니다. 무엇을 고를지
368
+ // 없는 질문이었고 기본값(github-ai)마저 죽어 있었다. 이제 워크플로우가 스스로
369
+ // 최선을 찾는다 PR 본문 존중 → Copilot → 외부 AI(키 있으면) → 커밋 분석.
370
+ // 저장값은 그대로 두되 죽은 값만 살아있는 것으로 옮긴다.
371
+ changelogProvider = migrateProvider(changelogProvider) ?? "commit";
359
372
 
360
373
  // ollama 선택 시에만 base_url 질문 (나머지 provider는 preset base_url 자동 — #455)
361
374
  if (changelogProvider === "ollama" && (ask("changelog") || changelogBaseUrl === null || changelogBaseUrl === "")) {
@@ -408,7 +421,9 @@ export async function askAllOptionalWorkflows({
408
421
  return {
409
422
  deploy: finalDeploy, publish: finalPublish, secretBackup: secretBackup === true,
410
423
  codeReviewCoderabbit: codeReviewCoderabbit === true,
411
- changelogProvider: changelogProvider ?? "github-ai",
424
+ // #566 AI 변경 요약 워크플로우 포함 여부. 기본 true(추가 설정 없이 바로 동작).
425
+ aiPrSummary: aiPrSummary !== false,
426
+ changelogProvider: changelogProvider ?? "commit",
412
427
  changelogBaseUrl: changelogBaseUrl ?? "",
413
428
  deployBranch: deployBranch ?? "develop",
414
429
  deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
@@ -25,8 +25,38 @@ function typeInventory(projectTypesDir, type) {
25
25
  return files;
26
26
  }
27
27
 
28
- // 선택 타입의 템플릿 워크플로우가 대상 레포에 실재하면 고아로 반환.
29
- export function detectOrphanWorkflows({ tempDir, targetRoot = ".", selectedTypes = [] }) {
28
+ // common/ 아래 "선택했을 때만 복사되는" 폴더들 (#566).
29
+ // 켜면 복사 엔진이 넣어주지만, 끄면 이미 있던 파일이 그대로 남아 계속 실행됐다.
30
+ // 축을 끈 사용자에게는 그게 곧 "껐는데 왜 도나"가 된다 — 여기서 고아로 잡는다.
31
+ // deploy는 타겟별 폴더라 선택된 타겟만 살리고 나머지를 대상으로 삼는다.
32
+ function commonOptionalOrphans(commonDir, opts) {
33
+ const { includeSecretBackup = false, aiPrSummary = true, deployTarget = "docker-ssh" } = opts;
34
+ const out = [];
35
+
36
+ const gated = [
37
+ ["secret-backup", includeSecretBackup, "secret-backup"],
38
+ ["pr-summary", aiPrSummary, "pr-summary"],
39
+ ];
40
+ for (const [dir, enabled, label] of gated) {
41
+ if (enabled) continue;
42
+ const d = join(commonDir, dir);
43
+ if (!exists(d)) continue;
44
+ for (const f of listYamlFiles(d)) out.push({ filename: f, type: label });
45
+ }
46
+
47
+ // deploy/<target> — 선택된 타겟 외 폴더는 전부 고아 후보
48
+ const deployRoot = join(commonDir, "deploy");
49
+ if (exists(deployRoot)) {
50
+ for (const e of readdirSync(deployRoot, { withFileTypes: true })) {
51
+ if (!e.isDirectory() || e.name === deployTarget) continue;
52
+ for (const f of listYamlFiles(join(deployRoot, e.name))) out.push({ filename: f, type: `deploy/${e.name}` });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // 선택 안 된 타입·옵션의 템플릿 워크플로우가 대상 레포에 실재하면 고아로 반환.
59
+ export function detectOrphanWorkflows({ tempDir, targetRoot = ".", selectedTypes = [], options = null }) {
30
60
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
31
61
  if (!exists(projectTypesDir)) return [];
32
62
  const selected = new Set(selectedTypes);
@@ -42,6 +72,14 @@ export function detectOrphanWorkflows({ tempDir, targetRoot = ".", selectedTypes
42
72
  if (existsSync(join(workflowsDir, f))) orphans.push({ filename: f, type: e.name });
43
73
  }
44
74
  }
75
+ // common의 조건부 폴더 — options를 준 호출부에서만 검사한다(구 호출부 동작 보존).
76
+ if (options) {
77
+ const commonDir = join(projectTypesDir, "common");
78
+ for (const o of commonOptionalOrphans(commonDir, options)) {
79
+ if (keep.has(o.filename)) continue;
80
+ if (existsSync(join(workflowsDir, o.filename))) orphans.push(o);
81
+ }
82
+ }
45
83
  return orphans.sort((a, b) => a.filename.localeCompare(b.filename));
46
84
  }
47
85
 
@@ -56,7 +56,7 @@ const HEADER = `# ==============================================================
56
56
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
57
57
  export function parseTemplateOptions(content) {
58
58
  const out = { deploy: null, publish: null, secretBackup: null,
59
- changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null,
59
+ changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null, aiPrSummary: null,
60
60
  deployBranch: null, intent: null, semverAuto: null, appRelease: null };
61
61
  // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
62
62
  for (const line of String(content || "").split("\n")) {
@@ -83,6 +83,10 @@ export function parseTemplateOptions(content) {
83
83
  if (inCodeReview) {
84
84
  const cm = line.match(/^\s+coderabbit:\s*(.+)/);
85
85
  if (cm) { const v = strip(cm[1]); if (v === "true") out.codeReviewCoderabbit = true; if (v === "false") out.codeReviewCoderabbit = false; continue; }
86
+ // #566 — AI 변경 요약 워크플로우 포함 여부. 키가 없으면 null(미설정)로 두어
87
+ // 기존 저장소가 업데이트할 때 마법사가 한 번 물어볼 수 있게 한다.
88
+ const am = line.match(/^\s+ai_summary:\s*(.+)/);
89
+ if (am) { const v = strip(am[1]); if (v === "true") out.aiPrSummary = true; if (v === "false") out.aiPrSummary = false; continue; }
86
90
  }
87
91
  if (inChangelog) {
88
92
  const pm = line.match(/^\s+provider:\s*(.+)/);
@@ -313,7 +317,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
313
317
  // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
314
318
  if (templateOptions) {
315
319
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
316
- changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true, intent = null, mode = null,
320
+ changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true, aiPrSummary = true, intent = null, mode = null,
317
321
  semverAuto = true, appRelease = null } = templateOptions;
318
322
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
319
323
  // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
@@ -338,6 +342,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
338
342
  }
339
343
  out += ` code_review:\n`;
340
344
  out += ` coderabbit: ${codeReviewCoderabbit}\n`;
345
+ out += ` ai_summary: ${aiPrSummary}\n`;
341
346
  out += ` changelog:\n`;
342
347
  out += ` provider: "${changelogProvider}"\n`;
343
348
  out += ` base_url: "${changelogBaseUrl}"\n`;
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
17
17
  import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
18
18
  import { appendGuideEntry } from "./core/migration-guide.js";
19
19
  import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
20
- import { applicableTargets } from "./core/options-ask.js";
20
+ import { applicableTargets, migrateProvider } from "./core/options-ask.js";
21
21
  import { printBannerCompact } from "./ui/banner.js";
22
22
  import { printSummary } from "./ui/summary.js";
23
23
  import { runFull } from "./commands/full.js";
@@ -197,13 +197,15 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
197
197
  deployTarget,
198
198
  publishTargets,
199
199
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
200
+ // #566 — CLI 값 우선, 없으면 저장값 보존, 그것도 없으면 true(설정 없이 바로 동작).
201
+ aiPrSummary: opts.aiPrSummary ?? existing?.options?.aiPrSummary ?? true,
200
202
  // #502 — version 모드가 기존 full 통합 기록(mode)을 강등하지 않도록 (full이 우세)
201
203
  recordMode: existing?.templateMode === "full" ? "full" : "version",
202
204
  // 릴리스 배포 브랜치(#456): CLI 플래그 → version.yml 저장값 → 빈 값(미출력, 스킬이 develop 폴백)
203
205
  deployBranch: opts.deployBranch || existing?.options?.deployBranch || "",
204
206
  intent,
205
207
  // changelog/code_review 축(#455): 비대화형은 저장값 → 기본값. null이 흘러 provider:"null"로 기록되던 버그 수정.
206
- changelogProvider: existing?.options?.changelogProvider ?? "github-ai",
208
+ changelogProvider: migrateProvider(existing?.options?.changelogProvider) ?? "commit",
207
209
  changelogBaseUrl: existing?.options?.changelogBaseUrl ?? "",
208
210
  codeReviewCoderabbit: existing?.options?.codeReviewCoderabbit ?? false,
209
211
  // semver 자동 승격(#546): 저장값 → (기존 통합 레포면 false / 신규면 true).
@@ -276,7 +278,9 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
276
278
  // 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
277
279
  if (recordArtifacts) {
278
280
  const orphans = trace.step("orphan-scan",
279
- () => detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types }),
281
+ () => detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types,
282
+ // 껐는데 남아 계속 도는 워크플로우도 함께 잡는다 (#566)
283
+ options: { includeSecretBackup: context.includeSecretBackup, aiPrSummary: context.aiPrSummary, deployTarget } }),
280
284
  { selectedTypes: types });
281
285
  orphanPending = orphans.map((o) => o.filename);
282
286
  for (const o of orphans) trace.event("orphan", "detected", o.filename, { type: o.type, action: "안내만(비대화형)" });
@@ -294,6 +298,39 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
294
298
  // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
295
299
  break;
296
300
  }
301
+ // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
302
+ let migrationGuidePath = null;
303
+ // 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
304
+ // 그래야 터미널 미러에 완료 화면까지 담긴다 (#561).
305
+ const files = recordArtifacts
306
+ ? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now })
307
+ : null;
308
+ if (recordArtifacts) {
309
+ migrationGuidePath = appendGuideEntry(cwd, {
310
+ now, mode: opts.mode, types, repoName,
311
+ templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
312
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease },
313
+ branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
314
+ breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
315
+ events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
316
+ traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
317
+ }).guidePath;
318
+ }
319
+
320
+ // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
321
+ printSummary({
322
+ mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
323
+ counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
324
+ verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
325
+ logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
326
+ logFile: files?.logFile ?? null,
327
+ traceFile: files?.traceFile ?? null,
328
+ }, cwd);
329
+
330
+ trace.event("run", "end", opts.mode || "", {
331
+ workflowsCopied: result?.workflows?.copied ?? 0,
332
+ workflowsSkipped: result?.workflows?.skipped ?? 0,
333
+ });
297
334
  } catch (err) {
298
335
  // 실패 원인을 로그에 남긴다 (#561) — 서버 로그처럼 사후에 바로 짚을 수 있어야 한다.
299
336
  trace.event("run", "error", opts.mode || "", {
@@ -302,53 +339,16 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
302
339
  });
303
340
  throw err;
304
341
  } finally {
305
- // 예외로 빠져나가도 기록을 남긴다 (#561). finalize는 멱등 정상 경로에서 이미
306
- // 호출됐으면 여기서는 아무 일도 하지 않는다.
342
+ // 어떤 경로로 빠져나가든 기록을 남긴다 (#561) 조기 return·중간 취소·예외 포함.
343
+ // 완료 화면 출력이 try 안에 있어야 그 화면까지 미러에 담긴 채로 여기서 닫힌다.
307
344
  if (recordArtifacts) {
308
345
  trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
346
+ } else {
347
+ trace.mirrorStop();
309
348
  }
310
349
  disarmSignals();
311
350
  remove(tempDir);
312
351
  }
313
352
 
314
- // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
315
- let migrationGuidePath = null;
316
- // 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
317
- // 그래야 터미널 미러에 완료 화면까지 담긴다 (#561).
318
- const files = recordArtifacts
319
- ? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now })
320
- : null;
321
- if (recordArtifacts) {
322
- migrationGuidePath = appendGuideEntry(cwd, {
323
- now, mode: opts.mode, types, repoName,
324
- templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
325
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease },
326
- branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
327
- breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
328
- events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
329
- traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
330
- }).guidePath;
331
- }
332
-
333
- // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
334
- printSummary({
335
- mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
336
- counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
337
- verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
338
- logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
339
- logFile: files?.logFile ?? null,
340
- traceFile: files?.traceFile ?? null,
341
- }, cwd);
342
-
343
- // 완료 화면까지 캡처한 뒤 종료하고 기록한다 (#561)
344
- trace.event("run", "end", opts.mode || "", {
345
- workflowsCopied: result?.workflows?.copied ?? 0,
346
- workflowsSkipped: result?.workflows?.skipped ?? 0,
347
- });
348
- if (recordArtifacts) {
349
- trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
350
- } else {
351
- trace.mirrorStop();
352
- }
353
353
  return 0;
354
354
  }
package/src/ui/summary.js CHANGED
@@ -131,23 +131,6 @@ export function printSummary(ctx, targetRoot = ".") {
131
131
  err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
132
132
  // #493 — 이번 실행의 마이그레이션 기록. "뭐가 남았고 AI에게 어떻게 시키는지"가 바로 보이게 행동 유도형으로 안내.
133
133
  // 실행 기록 위치 (#561) — 무슨 일이 있었는지 나중에 확인할 자리를 알린다.
134
- // 폴더가 아니라 이번 실행의 파일을 정확히 짚어준다 — 실행이 쌓이면 어느 것이 이번 건인지
135
- // 모른다. 붙여넣어 바로 열 수 있는 경로가 목적이다.
136
- if (ctx?.logFile || ctx?.logDir) {
137
- err(` 📁 실행 로그: ${ctx.logFile || `${ctx.logDir}/`}`);
138
- if (ctx.traceFile) err(` 이벤트(JSONL): ${ctx.traceFile}`);
139
- err(" 이번 실행에서 무엇을 어떤 근거로 정했는지 전부 기록돼 있습니다.");
140
- err(" 문제가 생기면 이 파일을 확인하세요 (저장소에 추적되지 않습니다).");
141
- err(" 💡 AI Agent에게 \"실행 로그 확인해줘\"라고 요청하면 원인을 짚어줍니다.");
142
- err("");
143
- }
144
- if (ctx?.migrationGuidePath) {
145
- err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
146
- err(" 이번 설치에서 바뀐 내용과 직접 확인해야 할 작업이 이 파일에 정리되어 있습니다.");
147
- err(" 💡 AI Agent(Claude, Cursor 등)에게 \"마이그레이션 가이드 확인하고");
148
- err(" 남은 작업 처리해줘\"라고 요청하면 이 문서를 읽고 대신 진행합니다.");
149
- }
150
- err("");
151
134
 
152
135
  // 설치 후 검증 결과 (#549) — 문제가 있을 때만 펼치고, 정상이면 한 줄로 압축한다.
153
136
  const verification = ctx?.verification;
@@ -215,4 +198,17 @@ export function printSummary(ctx, targetRoot = ".") {
215
198
  err(`${CYAN}📖 자세한 설정 방법은 다음 파일을 참고하세요:${NC}`);
216
199
  err(" → PROJECTOPS-SETUP-GUIDE.md");
217
200
  err("");
201
+ // 기록 안내는 완료 화면의 맨 끝에 둔다 (#561) — Secret 목록이 길면 위쪽은 스크롤에 묻혀
202
+ // 사용자가 "로그 얘기가 없다"고 느낀다. 문제가 생겼을 때 가장 먼저 찾는 것이므로 마지막 자리다.
203
+ if (ctx?.logFile || ctx?.logDir || ctx?.migrationGuidePath) {
204
+ err(`${CYAN}📁 이번 실행 기록${NC}`);
205
+ if (ctx?.logFile || ctx?.logDir) {
206
+ err(` 로그: ${ctx.logFile || `${ctx.logDir}/`}`);
207
+ if (ctx.traceFile) err(` 이벤트: ${ctx.traceFile}`);
208
+ }
209
+ if (ctx?.migrationGuidePath) err(` 가이드: ${ctx.migrationGuidePath}`);
210
+ err(" 무엇을 어떤 근거로 정했는지 전부 남아 있습니다 (저장소에 추적되지 않음).");
211
+ err(" 💡 문제가 생기면 AI Agent에게 \"실행 로그 확인해줘\"라고 요청하세요.");
212
+ err("");
213
+ }
218
214
  }