commitgate 0.9.8 → 0.9.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  이 프로젝트는 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
4
4
 
5
+ ## 0.9.10
6
+
7
+ **티켓을 끝낸 뒤 다음 티켓을 시작할 수 있습니다** (REQ-2026-057~059, 실제 Nuxt 소비자 프로젝트에 설치→사용→제거 전 과정을 따라간 감사의 후속).
8
+
9
+ - **작업 상태가 승인 증거와 함께 커밋됩니다 — durable state checkpoint** (REQ-2026-057). 지금까지 `req:commit`의 evidence-finalize는 `responses/`만 커밋하고 **소비된 상태는 커밋 뒤에 디스크에만** 썼습니다. 그래서 티켓을 정상 완주해도 `state.json`이 dirty로 남았고, 그 파일이 **다음 `req:new`의 clean-tree 게이트를 막았습니다**(`req:new`의 스크래치 예외는 증거 변조를 막으려고 `state.json`을 의도적으로 제외합니다). 그런데 계약과 문서는 `state.json`을 직접 커밋하지 말라고 하므로 **남겨도 막히고 버려도 안 되는** 상태였고, 실제로 버리면(문서가 지시하는 유일한 해소책) 커밋된 승인 증거가 있는데도 `req:next`가 **설계 재리뷰를 지시**했습니다(유료 Codex 호출). `git checkout <다른 브랜치>`도 같은 이유로 막혔습니다.
10
+
11
+ 이제 **design 승인 직후**와 **phase 소비 직후**에 해당 티켓의 `state.json` **한 경로만** 담는 pathspec 커밋을 발행합니다. 🔴 **순서를 바꾸지 않았습니다** — 소비를 evidence 커밋 앞으로 옮겨 한 커밋에 담으면 `consumeState`가 `pending_evidence_for`·`approval_evidence`를 제거하므로 커밋 실패 시 `req:commit --finalize` 복구가 근거를 잃습니다. 🔴 evidence 커밋의 **"`responses/` 외 staged 금지" 가드도 완화하지 않았습니다** — 상태는 자기 커밋으로 갑니다. 커밋 전에 디스크 내용이 도구가 방금 쓴 상태와 **바이트 동일한지**, `state.id`가 대상 티켓과 일치하는지 확인하고 아니면 fail-closed합니다. 변경이 없으면 커밋하지 않습니다(멱등). checkpoint 실패는 승인·커밋 판정을 바꾸지 않고 경고만 냅니다.
12
+
13
+ `computeReviewSemanticIdentity`에서 **`state.json`을 제외**했습니다. checkpoint가 인덱스의 그 항목을 갱신하므로, 제외하지 않으면 **방금 승인한 리뷰를 `req:next` G2가 stale로 오판**합니다 — `responses/`를 제외한 것과 같은 이유입니다. 승인 바인딩(D9)은 그대로라 방어가 약해지지 않습니다.
14
+
15
+ - **안내가 그대로 실행 가능해지고, 정상 상태가 실패처럼 보이지 않습니다** (REQ-2026-058).
16
+ - `req:next`가 사람 승인(`AWAIT_HUMAN`) 경로에서 출력하던 커밋 명령에 **메시지 자리표시자가 빠져** 있어, 그대로 실행하면 `req:doctor` 17개 체크를 모두 통과한 **뒤에** `커밋 메시지 필요`로 죽었습니다(LOW 자동 커밋 경로에만 자리표시자가 있었습니다). 두 경로가 **같은 상수**를 공유하도록 했습니다.
17
+ - HEAD에 증거가 아직 없는 **정상 상태**에서 git의 `fatal: path … does not exist in 'HEAD'`가 그대로 표출됐습니다(코드는 이미 `catch → null`로 처리하고 있었습니다). 부재가 정상인 조회 4곳에만 stderr를 버리는 runner를 씁니다 — **전역 억제가 아니라** 그 조회들에 한정하므로 진짜 오류의 진단은 그대로 보입니다.
18
+ - `commitgate uninstall` 계획이 도입 커밋 revert를 권하면서 그 커밋에 든 **`workflow/.gitignore`가 함께 사라져 기존 티켓의 scratch가 드러난다**는 파급을 예고하지 않았습니다(§3은 증거 보존을 지시하므로 두 안내가 서로를 무효화했습니다). 보존할 증거가 있을 때만 경고와 선택지를 냅니다. 그 밖에 Stage B에 존재하지 않는 `scripts/`를 잔여 후보에서 빼고, `not-installed` 판정에서도 **남아 있는 티켓 증거를 고지**하며, `_npx` 삭제가 CommitGate만이 아니라 **그 사용자의 모든 npx 패키지 캐시**를 지운다는 범위를 명시합니다.
19
+ - 설치 안내의 lockfile 인과 설명("2단계 install이 lockfile을 만든다")을 Stage B 사실대로 고쳤습니다 — lockfile을 바꾸는 것은 `init` **이전**의 `npm i -D commitgate`입니다.
20
+
21
+ - **테스트 픽스처 정리를 결정적으로** (REQ-2026-059). 새 near-e2e 픽스처의 임시 저장소 정리가 git의 **detached auto 유지보수**와 경합해 `ENOTEMPTY`로 간헐 실패했습니다(단언은 전부 통과, ubuntu·Node 20에서만 재현). 픽스처 저장소에서 `gc.auto`·`maintenance.auto`를 모두 끄고(git 버전별 두 경로), 정리에 짧은 재시도를 더했습니다. 단언은 변경하지 않았습니다.
22
+
23
+ 전체 테스트 1709 → 1729.
24
+
25
+ ## 0.9.9
26
+
27
+ **리뷰 게이트 운영 보강 4종** (REQ-2026-053~056, 소비 저장소 운영감사 후속). 모두 0.9.8 위의 **추가 기능**(신규 명령·additive 필드·opt-in)이라 기존 사용자는 무회귀입니다.
28
+
29
+ - **레거시 완료 티켓 마이그레이션 종결 — `req:close --migrate`** (REQ-2026-053). 0.9.8의 `req:next` DONE 게이트/intake가 close-proof·design 결속 도입 **이전에** 완료·병합된 durable 티켓을 영구 미종결로 분류해 **새 REQ 생성을 막던 워크플로 잠금**을 해소합니다. dev-complete를 흉내 내지 않는 별도 close 이벤트 **`migrated-complete`**(사후 스탬프 — `reconstructed:true`+근거 필수)를 두고, `req:close`가 HEAD-committed 증거 무결성·커밋된 design 승인·phase 증거·**본선 병합 여부(integrated)**·부분완료 여부(커밋된 phase 계획)를 검증한 티켓만 종결합니다. 🔴 완료성 판정의 mainline은 **신뢰된 ref**(`origin/HEAD`→`origin/main`→로컬 `main`)로만 해소하며 운영자 override를 받지 않습니다(임의 ref로 미병합 티켓을 통과시키는 우회 차단). dry-run 기본·재실행 멱등.
30
+
31
+ - **리뷰 호출 lifecycle 분류 + pre-dispatch 무차감 예산** (REQ-2026-054). 리뷰 attempt 실패를 `pre_dispatch_failed`(reviewer subprocess 미기동)·`dispatched_unknown`·`dispatch_confirmed`·`completed`로 분류해 원장(`review-ledger.jsonl`)에 **보상 `attempt-closed`**를 남깁니다 — 이전엔 실패가 조용한 unclosed로 뭉개져 "codex가 뜨지도 못한 실패"와 "모델이 부분 실행된 실패"가 구별되지 않았습니다. **명백한 pre-dispatch 실패(spawn 실패)만 회차를 환불**하고 dispatch 후·불명은 fail-closed로 차감합니다. 환불은 `attempts`를 감소시키지 않고(원장 자연키 충돌 회피) **`refunded_attempts` 별도 카운터**로 예산이 보는 유효 회차를 낮춥니다.
32
+
33
+ - **`req:review-exception` 전용 명령 + 구조화 rationale** (REQ-2026-055). 예산 needs-exception 구간(6~8회차)의 사람 예외를 `state.json` 수동 편집 대신 **검증·원자 기록**합니다. 대상 series·회차를 소비 게이트와 **같은 함수**로 계산해 오기를 막고, 구조화 rationale(직전 findings·이번 변경·미해결·재시도 근거)을 전용 `review-exceptions.jsonl`에 durable하게 남깁니다(릴리스된 `review-ledger` 스키마는 불변 — 필수 키 추가가 기존 커밋 원장을 깨뜨리지 않게 sibling 파일 사용). 🔴 **durable rationale을 먼저 커밋한 뒤에만** 소비 가능한 state를 기록해, 부분 실패 시 근거 없는 예외가 소비되지 않습니다. 소비 로직·예산 게이트는 무변경.
34
+
35
+ - **lockfile 리뷰 프롬프트 요약 + frozen-lockfile doctor D23** (REQ-2026-056). `git diff --cached`의 lockfile(수천 줄 기계생성) 구획을 리뷰 프롬프트에서 **요약**(경로·변경 통계·생략분 SHA-256)으로 대체해 토큰과 리뷰 노이즈를 줄입니다. 🔴 **승인 바인딩(reviewTree)은 불변**이라 승인은 여전히 전체 lockfile을 결속하고, 요약은 리뷰어가 보는 프롬프트만 바꿉니다(측정 로그·원장 prompt 해시는 전송된 요약본 기준이라 자동 정합). config **`lockfilePromptFull`**(기본 false)로 전문 opt-in. `req:doctor` **D23**은 감지된 패키지 매니저의 lockfile이 없거나 untracked면 **WARN**합니다(FAIL 아님 — 재현 가능한 설치 안내). 경로 판별은 rename(한쪽만 lockfile)·git quoted(공백) 경로까지 처리합니다.
36
+
37
+ 전체 테스트 1363 → 1709.
38
+
5
39
  ## 0.9.8
6
40
 
7
41
  **design 승인 증거가 커밋 이력에 확실히 남습니다** (REQ-2026-048). CommitGate는 `state.json`을 의도적으로 커밋하지 않으므로 저장소의 감사 정본은 `approvals.jsonl` 매니페스트와 커밋된 응답 아카이브뿐인데, 그 정본을 만드는 경로가 비대칭이었습니다 — **phase 증거는 매 `req:commit`이 자동 커밋**(needs-fix 라운드 포함)하는 반면 **design 증거는 수동 `req:commit --finalize-design`에만 의존**했고, 그마저 승인본 1건만 커밋했습니다. 게다가 그 수동 단계는 어떤 도구 출력·문서에도 안내되지 않았고, 커밋된 매니페스트를 확인하는 게이트도 없었습니다(D13은 미커밋 `state.json` 플래그를, D17은 **온디스크** 아카이브를 봅니다). 그 결과 REQ가 "전 phase 커밋 + 병합"에 도달해도 **설계 승인 증거가 커밋 이력에 전혀 남지 않을 수 있었고 아무 게이트도 불평하지 않았습니다** — 소비자 저장소에서 실측된 사고입니다.
package/bin/dispatch.mjs CHANGED
@@ -18,6 +18,9 @@ export const VERB_MODULES = {
18
18
  'req:review-codex': '../scripts/req/review-codex.ts',
19
19
  'req:doctor': '../scripts/req/req-doctor.ts',
20
20
  'req:commit': '../scripts/req/req-commit.ts',
21
+ 'req:reconstruct': '../scripts/req/req-reconstruct.ts',
22
+ 'req:close': '../scripts/req/req-close.ts',
23
+ 'req:review-exception': '../scripts/req/req-review-exception.ts',
21
24
  uninstall: 'uninstall.ts',
22
25
  migrate: 'migrate.ts',
23
26
  sync: 'sync.ts',
package/bin/init.ts CHANGED
@@ -37,6 +37,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
37
37
  import { loadConfig, stripBom, DEFAULT_REVIEW_PERSONA_RELPATH, type PackageManager } from '../scripts/req/lib/config'
38
38
  import { createGitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
39
39
  import { parseStatusZ, entryPaths, STATUS_Z_ARGS, type StatusEntry } from '../scripts/req/lib/porcelain'
40
+ import { VERB_MODULES } from './dispatch.mjs' // 🔴 DEC-D3: 현재 Stage-B req 명령 표면 SSOT.
40
41
  import * as semver from 'semver'
41
42
 
42
43
  /** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
@@ -177,15 +178,27 @@ export const REQ_SCRIPTS: Record<string, string> = {
177
178
  'req:commit': 'tsx scripts/req/req-commit.ts',
178
179
  }
179
180
 
181
+ /**
182
+ * 🔴 **현재 Stage-B 명령 표면의 SSOT = `bin/dispatch.mjs`의 `VERB_MODULES` req:* verb**(REQ-2026-052 DEC-D3).
183
+ *
184
+ * ⚠️ `REQ_SCRIPTS`(Stage-A 서명)에서 파생하지 **않는다**. Stage-A 서명은 "과거에 무엇을 주입했는가"의 frozen
185
+ * 기록이고, 현재 설치·마이그레이션이 다뤄야 하는 명령 집합은 **dispatch가 실제로 노출하는 verb 표면**이다.
186
+ * dispatch에 verb를 추가하면(예: `req:reconstruct`) 이 목록·init 주입·migrate·smoke가 **자동으로** 따라간다
187
+ * — 정합성 테스트(`dispatch req:* === STAGE_B_REQ_SCRIPTS 키`)와 tarball smoke가 누락을 CI에서 잡는다.
188
+ */
189
+ export const STAGE_B_REQ_VERBS: string[] = Object.keys(VERB_MODULES)
190
+ .filter((v) => v.startsWith('req:'))
191
+ .sort()
192
+
180
193
  /**
181
194
  * **Stage B**가 주입하는 req:* 스크립트 값 — 로컬 패키지 bin을 dispatch한다(REQ-2026-014 R1/R2).
182
- * 키 집합은 `REQ_SCRIPTS`에서 파생해 SSOT를 하나로 유지한다(값만 다르고 키는 같다).
195
+ * 키 집합은 **dispatch 표면(`STAGE_B_REQ_VERBS`)에서 파생**한다(DEC-D3 REQ_SCRIPTS 아님).
183
196
  *
184
197
  * `npm run req:new -- <args>` → `commitgate req:new <args>` → `node_modules/.bin/commitgate`
185
198
  * → `bin/commitgate.mjs`가 verb를 `scripts/req/req-new.ts`(**패키지 안**)로 dispatch.
186
199
  */
187
200
  export const STAGE_B_REQ_SCRIPTS: Record<string, string> = Object.fromEntries(
188
- Object.keys(REQ_SCRIPTS).map((k) => [k, `commitgate ${k}`]),
201
+ STAGE_B_REQ_VERBS.map((k) => [k, `commitgate ${k}`]),
189
202
  )
190
203
 
191
204
  /**
@@ -1365,7 +1378,12 @@ export function installGuidance(r: InitResult): string[] {
1365
1378
  out.push(` ${n++}. 설치분만 stage 하십시오. 전체를 담는 stage(-A / .)는 쓰지 마십시오 — 무관한 변경·.env 가`)
1366
1379
  out.push(` 함께 커밋되고, 이어지는 req:review-codex 가 staged diff 전문을 외부로 전송합니다.`)
1367
1380
  if (r.lockfileRel !== null)
1368
- out.push(` (2단계 install 먼저 실행해야 ${r.lockfileRel}존재합니다. lockfile 만들지 않는 설정이라면 경로는 빼십시오.)`)
1381
+ // 🔴 REQ-2026-058 F-4: Stage B에서파일을 바꾸는 것은 **init 앞의** `npm i -D commitgate`(D14가 요구)이지
1382
+ // 안내 2단계의 `install`이 아니다. init 자신은 devDeps를 주입하지 않으므로 lockfile을 건드리지 않는다.
1383
+ // 이미 커밋했다면 아래 add는 무해한 no-op이고, 아직 미커밋이면 이 경로가 설치 커밋에 함께 담겨야 한다.
1384
+ out.push(
1385
+ ` (${r.lockfileRel} 은 선행 'npm i -D commitgate' 가 갱신한 것입니다 — 이미 커밋했다면 이 경로는 빼도 됩니다. lockfile 을 만들지 않는 설정도 마찬가지입니다.)`,
1386
+ )
1369
1387
  out.push(` git add -- ${toStage.map(quoteForShell).join(' ')}`)
1370
1388
  out.push(` git status # 의도한 것만 staged 인지 눈으로 확인`)
1371
1389
  out.push(` git commit -m "chore: install commitgate"`)
package/bin/migrate.ts CHANGED
@@ -38,16 +38,22 @@ export interface MigrateOptions {
38
38
  export interface ScriptDecision {
39
39
  key: string
40
40
  current: string | undefined
41
- /** 'convert' = 정확한 Stage A 값 → 전환 대상. 'stage-b' = 이미 전환됨. 'custom' = 사용자 값(보존). 'absent' = 키 없음. */
42
- kind: 'convert' | 'stage-b' | 'custom' | 'absent'
41
+ /**
42
+ * 'convert' = 정확한 Stage A 값 → 전환 대상. 'stage-b' = 이미 전환됨. 'custom' = 사용자 값(보존).
43
+ * 'absent' = 역사적 verb 키 부재(무변경, 기존 동작). 'add' = **신규 Stage-B verb**(Stage-A 서명 없음)가 부재 →
44
+ * 현재 표면으로 올리며 Stage-B 값을 **추가**(DEC-D3 — 예: `req:reconstruct`).
45
+ */
46
+ kind: 'convert' | 'stage-b' | 'custom' | 'absent' | 'add'
43
47
  next: string | undefined
44
48
  }
45
49
 
46
50
  export interface MigratePlan {
47
51
  targetRoot: string
48
52
  decisions: ScriptDecision[]
49
- /** 실제로 바꿀 키(kind==='convert'). 비어 있으면 쓸 것이 없다. */
53
+ /** Stage-A 값을 Stage-B로 바꿀 키(kind==='convert'). */
50
54
  converts: ScriptDecision[]
55
+ /** 🔴 신규 Stage-B verb 추가 키(kind==='add', DEC-D3) — reconstruct 등. converts와 함께 write된다. */
56
+ adds: ScriptDecision[]
51
57
  /** 보존하는 사용자 정의 키(kind==='custom') — 수동 조치 안내 대상. */
52
58
  customs: ScriptDecision[]
53
59
  /** vendored `scripts/req/**`가 남아 있는가(삭제하지 않는다 — 안내만). */
@@ -61,14 +67,19 @@ export interface MigratePlan {
61
67
  * `REQ_SCRIPTS`가 그 SSOT다. 사용자 정의 값(`req:new = "node custom.mjs"` 등)은 **절대 덮어쓰지 않는다**.
62
68
  */
63
69
  export function decideScripts(scripts: Record<string, string>): ScriptDecision[] {
64
- return Object.keys(REQ_SCRIPTS).map((key) => {
70
+ // 🔴 DEC-D3: **현재 Stage-B 표면(dispatch 파생)**을 순회한다 — REQ_SCRIPTS(역사적 5) 아니다.
71
+ // 그래야 신규 verb(reconstruct)도 판정 대상에 들어와 부재 시 add된다.
72
+ return Object.keys(STAGE_B_REQ_SCRIPTS).map((key) => {
65
73
  const current = scripts[key]
66
- const stageA = REQ_SCRIPTS[key]
74
+ const stageA = REQ_SCRIPTS[key] // 신규 verb면 undefined(Stage-A 서명 없음)
67
75
  const stageB = STAGE_B_REQ_SCRIPTS[key]
68
- if (current === undefined) return { key, current, kind: 'absent' as const, next: undefined }
69
- if (current === stageA) return { key, current, kind: 'convert' as const, next: stageB }
70
76
  if (current === stageB) return { key, current, kind: 'stage-b' as const, next: undefined }
71
- return { key, current, kind: 'custom' as const, next: undefined }
77
+ if (stageA !== undefined && current === stageA) return { key, current, kind: 'convert' as const, next: stageB }
78
+ if (current === undefined) {
79
+ // Stage-A 서명이 없는 **신규** verb만 add(현재 표면으로 올림). 역사적 verb의 부재는 기존 동작(absent·무변경) 보존.
80
+ return stageA === undefined ? { key, current, kind: 'add' as const, next: stageB } : { key, current, kind: 'absent' as const, next: undefined }
81
+ }
82
+ return { key, current, kind: 'custom' as const, next: undefined } // 사용자 정의(reconstruct 포함) 보존
72
83
  })
73
84
  }
74
85
 
@@ -89,6 +100,7 @@ export function planMigrate(opts: MigrateOptions): MigratePlan {
89
100
  targetRoot,
90
101
  decisions,
91
102
  converts: decisions.filter((d) => d.kind === 'convert'),
103
+ adds: decisions.filter((d) => d.kind === 'add'),
92
104
  customs: decisions.filter((d) => d.kind === 'custom'),
93
105
  vendoredPresent: existsSync(join(targetRoot, KIT_SOURCE_DIR_REL)),
94
106
  }
@@ -119,6 +131,11 @@ export function renderPlan(plan: MigratePlan, apply: boolean): string[] {
119
131
  L.push(` 대상: ${plan.targetRoot}`)
120
132
  L.push('')
121
133
 
134
+ if (plan.adds.length > 0) {
135
+ L.push(` 🔴 신규 Stage-B verb 추가 ${plan.adds.length}개(현재 dispatch 표면, 예: req:reconstruct):`)
136
+ for (const d of plan.adds) L.push(` package.json#scripts.${d.key} = "${d.next}" (신규)`)
137
+ L.push('')
138
+ }
122
139
  if (plan.converts.length > 0) {
123
140
  L.push(` 전환 대상 — 현재 값이 정확히 Stage A 주입값인 키 ${plan.converts.length}개:`)
124
141
  for (const d of plan.converts) L.push(` package.json#scripts.${d.key}: "${d.current}" → "${d.next}"`)
@@ -148,7 +165,7 @@ export function renderPlan(plan: MigratePlan, apply: boolean): string[] {
148
165
  if (!apply) {
149
166
  L.push(' 적용하려면: npx commitgate migrate --apply')
150
167
  L.push(' (--apply 는 package.json 만 씁니다. 커밋하지 않습니다 — 직접 검토 후 stage/commit 하십시오.)')
151
- } else if (plan.converts.length > 0) {
168
+ } else if (plan.converts.length > 0 || plan.adds.length > 0) {
152
169
  L.push(' 다음: git diff package.json 으로 확인한 뒤 커밋하십시오.')
153
170
  L.push(' git add -- package.json')
154
171
  L.push(' git commit -m "chore: migrate commitgate to Stage B runtime"')
@@ -174,9 +191,10 @@ export function runMigrate(opts: MigrateOptions): MigratePlan {
174
191
  `devDependencies.commitgate 선언이 없습니다 — Stage B 는 req:* 를 'commitgate <verb>' 로 심으므로 ` +
175
192
  `대상에 commitgate 가 devDependency 로 있어야 합니다. 먼저 'npm install -D commitgate' 를 실행한 뒤 다시 시도하십시오.`,
176
193
  )
177
- if (plan.converts.length > 0) {
194
+ const writes = [...plan.converts, ...plan.adds] // 🔴 DEC-D3: convert(전환) + add(신규 verb) 모두 쓴다.
195
+ if (writes.length > 0) {
178
196
  const scripts = pkg.scripts ?? {}
179
- for (const d of plan.converts) if (d.next !== undefined) scripts[d.key] = d.next
197
+ for (const d of writes) if (d.next !== undefined) scripts[d.key] = d.next
180
198
  pkg.scripts = scripts
181
199
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
182
200
  }
package/bin/sync.ts CHANGED
@@ -11,9 +11,11 @@
11
11
  * 하는 일(비파괴·멱등):
12
12
  * - **스키마 축(`KIT_SCHEMA_RELPATHS`)**: machine.schema.json + req.config.schema.json을 `PACKAGE_ROOT/<rel>` →
13
13
  * `<targetRoot>/<rel>`로 복사한다(계약 = --force 축, 커스터마이즈 대상 아님). sha 동일이면 skip(멱등).
14
- * - **페르소나(`--persona` opt-in)**: 파괴적 쓰기 0건. **부재 복원만** 한다(부재면 loadReviewPersona가 이미
15
- * fail-closed로 리뷰를 멈추므로 복원이 순이득). 내용이 다르면 사용자 편집일 있어 **덮지 않고 report-only**
16
- * (manifest 없이 stale-kit↔사용자편집 구별 불가 편집 보존 방향; design-r02 P1). custom 경로·null은 unmanaged.
14
+ * - **페르소나(`--persona` opt-in)**: 기본은 파괴적 쓰기 0건. **부재 복원**(부재면 loadReviewPersona가 이미
15
+ * fail-closed로 리뷰를 멈추므로 복원이 순이득) + 내용이 다르면 **적용 실제 내용 diff 출력 후 미접촉**.
16
+ * REQ-2026-050부터 `--persona-apply`를 **함께** 주면 백업(.bak) 교체한다 전까지는 갱신 경로가
17
+ * 아예 없어 배포된 리뷰 정책이 기존 프로젝트에 도달하지 못했다. 마커는 차단 조건이 아니라 **경고 강도**다
18
+ * (마커로 게이팅하면 pre-050 설치분 전체가 봉쇄된다; design-r02 P1). custom 경로·null은 unmanaged.
17
19
  *
18
20
  * - **`workflow/.gitignore`(`--gitignore` opt-in, REQ-2026-047)**: 덮어쓰기 0건. kit 템플릿 규칙 중 **없는 행만**
19
21
  * 말미에 append한다(기존 행 미변경·미재정렬·미삭제). 파일 부재면 템플릿 전체로 생성(= 전 규칙 누락의 경계 사례).
@@ -37,6 +39,7 @@ import { existsSync, copyFileSync, mkdirSync, realpathSync, readFileSync, writeF
37
39
  import { resolve, join, dirname, relative } from 'node:path'
38
40
  import { pathToFileURL } from 'node:url'
39
41
  import { loadConfig, DEFAULT_REVIEW_PERSONA_RELPATH, type ResolvedConfig } from '../scripts/req/lib/config'
42
+ import { safeSpawnSyncStatus } from '../scripts/req/lib/adapters'
40
43
  import {
41
44
  PACKAGE_ROOT,
42
45
  KIT_SCHEMA_RELPATHS,
@@ -58,6 +61,11 @@ export interface SyncOptions {
58
61
  * 생략 가능: 기존 호출부(3필드)를 깨지 않는다.
59
62
  */
60
63
  gitignore?: boolean
64
+ /**
65
+ * persona가 shipped와 다를 때 **교체**를 허용하는 opt-in(REQ-2026-050 D7). 없으면 기본 동작 그대로 미접촉.
66
+ * 🔴 `persona`를 **함의하지 않는다** — 둘을 함께 줘야 한다(우발적 교체를 막는 의도적 중복).
67
+ */
68
+ personaApply?: boolean
61
69
  }
62
70
 
63
71
  /**
@@ -65,19 +73,33 @@ export interface SyncOptions {
65
73
  * - `new`: dest 부재 → 복사(스키마/페르소나 공통, 잃을 것 없음).
66
74
  * - `in-sync`: sha 동일 → skip.
67
75
  * - `stale`: 스키마가 shipped와 다름 → 덮음(계약 = --force 축).
68
- * - `preserved-differs`: 기본 경로 페르소나가 shipped와 다름**미접촉**(사용자 편집 보존, report-only).
69
- * - `unmanaged-custom` / `unmanaged-null`: 페르소나 경로가 custom/null → 미접촉.
76
+ * - `managed-drift`: 기본 경로 페르소나가 shipped와 다르고 **kit 마커가 있음** 기본 미접촉 + 적용 전 diff,
77
+ * `--persona-apply`로 백업 교체.
78
+ * - `preserved-differs`: 위와 같으나 **마커가 없음**(직접 작성분일 수 있음) → 경로는 동일하고 **경고만 강하다**.
79
+ * - `unmanaged-custom` / `unmanaged-null`: 페르소나 경로가 custom/null → 미접촉(교체 경로 없음).
70
80
  */
71
81
  export type AssetStatus =
72
82
  | 'new'
73
83
  | 'in-sync'
74
84
  | 'stale'
85
+ /** persona 축: kit 마커 有 · shipped와 다름 (REQ-2026-050 D4). 기본 미접촉, `--persona-apply`로 교체 가능. */
86
+ | 'managed-drift'
87
+ /** persona 축: kit 마커 無 · shipped와 다름. `managed-drift`와 동일 경로지만 **경고가 강하다**(사용자 작성분일 수 있음). */
75
88
  | 'preserved-differs'
76
89
  | 'unmanaged-custom'
77
90
  | 'unmanaged-null'
78
91
  /** gitignore 축: 파일은 있으나 kit 규칙 일부가 없음 → **누락 행만** 말미에 append(REQ-2026-047). */
79
92
  | 'rules-missing'
80
93
 
94
+ /** persona 본문이 kit 계보임을 표시하는 마커(REQ-2026-050 phase-1이 도입). 첫 줄에 온다. */
95
+ export const PERSONA_KIT_MARKER = '<!-- commitgate:persona v1 -->'
96
+
97
+ /** 마커 판정 — 선행 BOM·공백과 개행 형태(LF/CRLF)에 무관하게 **첫 줄**만 본다. */
98
+ export function hasPersonaKitMarker(body: string): boolean {
99
+ const first = body.replace(/^/, '').split(/\r?\n/)[0] ?? ''
100
+ return first.trim() === PERSONA_KIT_MARKER
101
+ }
102
+
81
103
  export interface AssetPlan {
82
104
  rel: string // 대상-상대 경로(표시용)
83
105
  axis: 'schema' | 'persona' | 'gitignore'
@@ -92,6 +114,14 @@ export interface GitignoreAppend {
92
114
  missing: string[]
93
115
  }
94
116
 
117
+ /** persona 교체 직전 원본을 보존하는 백업(REQ-2026-050 D6). 실패하면 교체하지 않는다. */
118
+ export interface PersonaBackup {
119
+ /** 백업 원본(대상 repo의 현재 persona) — 대상-상대. */
120
+ srcRel: string
121
+ /** 백업 대상 — 대상-상대. 직전 1세대만 보장(기존 파일은 덮어쓴다). */
122
+ bakRel: string
123
+ }
124
+
95
125
  export interface SyncPlan {
96
126
  targetRoot: string
97
127
  assets: AssetPlan[]
@@ -99,6 +129,16 @@ export interface SyncPlan {
99
129
  writes: { srcAbs: string; destRel: string }[]
100
130
  /** apply 시 **행 단위 append**할 항목(gitignore 축 전용). 복사가 아니라 추가라 writes와 분리한다. */
101
131
  appends: GitignoreAppend[]
132
+ /**
133
+ * apply 시 writes **보다 먼저** 수행할 백업(REQ-2026-050 D6). 현재는 persona 교체 경로만 쓴다.
134
+ * 백업이 실패하면 대응하는 write도 수행하지 않는다(fail-closed) — runSync가 강제한다.
135
+ */
136
+ backups: PersonaBackup[]
137
+ /**
138
+ * persona가 shipped와 달라 사용자 판단이 필요한 경우의 diff 대상(REQ-2026-050 D5).
139
+ * `--apply` 여부와 무관하게 인쇄한다 — dry-run에서 봐야 적용 여부를 고를 수 있다.
140
+ */
141
+ personaDiff: { shippedAbs: string; targetAbs: string; targetRel: string; unmarked: boolean } | null
102
142
  }
103
143
 
104
144
  /**
@@ -153,10 +193,18 @@ function canonical(p: string): string {
153
193
  * 재동기화 계획 수립(순수 판정 — 쓰기 없음). statWritableDest로 confinement + leaf를 판정하고 sha로 멱등 skip.
154
194
  * `--persona` 없으면 페르소나는 계획에 넣지 않는다(완전 미접촉).
155
195
  */
156
- export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boolean, gitignore = false): SyncPlan {
196
+ export function planSync(
197
+ targetRoot: string,
198
+ cfg: ResolvedConfig,
199
+ persona: boolean,
200
+ gitignore = false,
201
+ personaApply = false,
202
+ ): SyncPlan {
157
203
  const assets: AssetPlan[] = []
158
204
  const writes: { srcAbs: string; destRel: string }[] = []
159
205
  const appends: GitignoreAppend[] = []
206
+ const backups: PersonaBackup[] = []
207
+ let personaDiff: SyncPlan['personaDiff'] = null
160
208
 
161
209
  // ── 스키마 축(무조건 재동기화 — 계약, --force 축) ──
162
210
  for (const rel of KIT_SCHEMA_RELPATHS) {
@@ -193,13 +241,27 @@ export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boole
193
241
  } else if (sha256File(srcAbs) === sha256File(defaultAbs)) {
194
242
  assets.push({ rel: personaRel, axis: 'persona', status: 'in-sync' })
195
243
  } else {
196
- // 🔴 다름 → 절대 덮지 않는다(사용자 편집 보존). manifest 없이 stale-kit과 편집을 구별 함(design-r02 P1).
244
+ // 다름 → 기본은 여전히 **미접촉**. 다만 REQ-2026-050 D4부터 `--persona-apply` 명시 교체 경로가 열린다.
245
+ //
246
+ // 🔴 마커 유무로 **교체를 막지 않는다.** 마커는 0.9.9+ 가 깐 사본에만 있으므로, 마커를 게이트로 쓰면
247
+ // pre-050 설치분 **전체**의 갱신 경로가 봉쇄돼 정책이 기존 사용자에게 영영 도달하지 못한다(design-r02 P1).
248
+ // "kit 사본인가 사용자 작성분인가"의 판정은 도구가 아니라 **사용자**가 한다 — 그래서 적용 전 실제
249
+ // 내용 diff(D5)를 보여주고, 이중 플래그를 요구하고, 교체 전 백업(D6)을 남긴다.
250
+ // 마커가 하는 일은 **경고 강도**를 가르는 것뿐이다.
251
+ const unmarked = !hasPersonaKitMarker(readFileSync(defaultAbs, 'utf8'))
197
252
  assets.push({
198
253
  rel: personaRel,
199
254
  axis: 'persona',
200
- status: 'preserved-differs',
201
- note: '기본 persona가 shipped와 다름 — 사용자 편집이면 유지, stale면 직접 교체(미접촉)',
255
+ status: unmarked ? 'preserved-differs' : 'managed-drift',
256
+ note: unmarked
257
+ ? '기본 persona가 shipped와 다르고 kit 마커가 없음 — **당신이 직접 쓴 파일일 수 있다**. diff 확인 후 --persona-apply 로만 교체'
258
+ : 'kit 계보(마커 有)인데 shipped와 다름 — diff 확인 후 --persona-apply 로 교체 가능',
202
259
  })
260
+ personaDiff = { shippedAbs: srcAbs, targetAbs: defaultAbs, targetRel: personaRel, unmarked }
261
+ if (personaApply) {
262
+ backups.push({ srcRel: personaRel, bakRel: `${personaRel}.bak` })
263
+ writes.push({ srcAbs, destRel: personaRel })
264
+ }
203
265
  }
204
266
  }
205
267
  }
@@ -230,7 +292,72 @@ export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boole
230
292
  }
231
293
  }
232
294
 
233
- return { targetRoot, assets, writes, appends }
295
+ return { targetRoot, assets, writes, appends, backups, personaDiff }
296
+ }
297
+
298
+ // ────────────────────────────────────────────── persona diff (D5) ──
299
+
300
+ /**
301
+ * persona 차이의 **실제 내용 diff** 생산자(REQ-2026-050 D5). 실패는 throw — 호출자가 fail-closed로 처리한다.
302
+ *
303
+ * 🔴 손수 구현하지도, `diff` 라이브러리를 새로 넣지도 않는다. **git에 위임한다** — `bin/sync.ts`는 이미
304
+ * `assertGitWorkTree`로 git을 하드 전제로 두므로 새 의존성이 0이다. 손수 명세한 diff/oracle이 설계
305
+ * 리뷰를 미수렴시킨 전례(REQ-2026-041→042)를 반복하지 않는다.
306
+ */
307
+ export type PersonaDiffRunner = (shippedAbs: string, targetAbs: string) => string
308
+
309
+ /**
310
+ * `runSync`의 주입 가능한 부작용 경계(REQ-2026-050 phase-2). 프로덕션 기본값은 실제 git·fs다.
311
+ * 테스트는 여기에 stub을 넣어 **실제 `git`을 호출하지 않고** diff/백업 실패 분기와 호출 **순서**를 검증한다.
312
+ */
313
+ export interface SyncDeps {
314
+ diff?: PersonaDiffRunner
315
+ backup?: (srcAbs: string, bakAbs: string) => void
316
+ log?: (line: string) => void
317
+ }
318
+
319
+ /**
320
+ * 기본 러너 — `git diff --no-index --no-color -- <shipped> <target>`.
321
+ *
322
+ * ⚠️ **exit 1은 정상이다**(내용이 다르다는 신호). 0·1만 받고 **2 이상만 오류**로 throw한다.
323
+ * 기존 `createGitAdapter().exec`는 non-zero에서 throw하므로 이 용도에 쓸 수 없다 —
324
+ * exit code를 보존하는 `safeSpawnSyncStatus`를 쓴다(shell 없는 cross-spawn 경로는 동일).
325
+ */
326
+ export const defaultPersonaDiffRunner: PersonaDiffRunner = (shippedAbs, targetAbs) => {
327
+ const r = safeSpawnSyncStatus('git', ['diff', '--no-index', '--no-color', '--', shippedAbs, targetAbs])
328
+ if (r.status !== 0 && r.status !== 1)
329
+ throw new Error(`git diff --no-index 실패(exit=${r.status ?? 'null'}): ${r.stderr.trim()}`.trim())
330
+ return r.stdout
331
+ }
332
+
333
+ /** diff 출력 상한(행). 초과분은 자르고 shipped 원본 절대경로를 안내해 사용자가 자기 도구로 전체를 본다. */
334
+ export const PERSONA_DIFF_MAX_LINES = 200
335
+
336
+ /**
337
+ * diff 블록 렌더(순수). `text`가 비면 "차이 없음"이 아니라 **git이 빈 출력을 냈다**는 뜻이라 그대로 알린다.
338
+ * 절단 시 남은 행 수와 shipped 절대경로를 반드시 함께 낸다 — 그래야 "정보에 기반한 선택"이 성립한다.
339
+ */
340
+ export function renderPersonaDiff(
341
+ text: string,
342
+ d: NonNullable<SyncPlan['personaDiff']>,
343
+ maxLines = PERSONA_DIFF_MAX_LINES,
344
+ ): string[] {
345
+ const L: string[] = ['', `── persona 차이 — ${d.targetRel} (좌: shipped / 우: 현재 파일) ──`]
346
+ if (d.unmarked)
347
+ L.push(' ⚠️ 이 파일에는 kit 마커가 없습니다 — **당신이 직접 작성했을 수 있습니다.** 아래 diff를 반드시 확인하세요.')
348
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
349
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
350
+ if (lines.length === 0) {
351
+ L.push(' (diff 출력이 비어 있습니다 — 내용은 다르지만 git이 표시할 텍스트 차이를 내지 않았습니다)')
352
+ } else {
353
+ for (const line of lines.slice(0, maxLines)) L.push(` ${line}`)
354
+ if (lines.length > maxLines) {
355
+ L.push(` … ${lines.length - maxLines}행 더 있음(출력 상한 ${maxLines}행에서 잘림)`)
356
+ L.push(` 전체 비교: shipped 원본 = ${d.shippedAbs}`)
357
+ }
358
+ }
359
+ L.push('')
360
+ return L
234
361
  }
235
362
 
236
363
  /** 계획을 사람이 읽는 줄 배열로. shell 연산자 미사용(Windows PowerShell/cmd 호환 — DEC-011-8). */
@@ -240,6 +367,7 @@ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean, git
240
367
  new: '+',
241
368
  'in-sync': '=',
242
369
  stale: '~',
370
+ 'managed-drift': '!',
243
371
  'preserved-differs': '!',
244
372
  'unmanaged-custom': '·',
245
373
  'unmanaged-null': '·',
@@ -285,7 +413,8 @@ const STATUS_LABEL: Record<AssetStatus, string> = {
285
413
  new: '부재 → 복원',
286
414
  'in-sync': '최신(변경 없음)',
287
415
  stale: 'stale → 갱신',
288
- 'preserved-differs': '차이 감지 → 보존(수동 확인)',
416
+ 'managed-drift': 'kit 계보 · 차이 감지 → 기본 보존(--persona-apply 로 교체 가능)',
417
+ 'preserved-differs': '마커 없음 · 차이 감지 → 기본 보존(--persona-apply 로 교체 가능 — 직접 작성분일 수 있음)',
289
418
  'unmanaged-custom': 'custom 경로(unmanaged)',
290
419
  'unmanaged-null': '비활성(unmanaged)',
291
420
  'rules-missing': 'kit 규칙 누락 → 말미에 추가(기존 행 미변경)',
@@ -297,7 +426,11 @@ const STATUS_LABEL: Record<AssetStatus, string> = {
297
426
  * 🔴 packageRoot 가드: `targetRoot===PACKAGE_ROOT`면 어떤 쓰기 전에도 거부(fail-closed). CommitGate 패키지 자신을
298
427
  * 재작성하는 사고를 막는다. `loadConfig({root})` 명시로 resolveRoot fallback도 원천 차단(이중 방어).
299
428
  */
300
- export function runSync(opts: SyncOptions): SyncPlan {
429
+ export function runSync(opts: SyncOptions, deps: SyncDeps = {}): SyncPlan {
430
+ const log = deps.log ?? ((line: string) => console.log(line))
431
+ const diffRunner = deps.diff ?? defaultPersonaDiffRunner
432
+ const backupFile = deps.backup ?? ((srcAbs: string, bakAbs: string) => copyFileSync(srcAbs, bakAbs))
433
+
301
434
  const targetRoot = resolve(opts.dir)
302
435
  if (!existsSync(targetRoot)) throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
303
436
  assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
@@ -305,9 +438,44 @@ export function runSync(opts: SyncOptions): SyncPlan {
305
438
  throw new Error('sync 대상이 CommitGate 패키지 자신입니다 — 소비 repo(commitgate를 devDependency로 설치한 곳)에서 실행하세요.')
306
439
 
307
440
  const cfg = loadConfig({ root: targetRoot }) // root 명시 → resolveRoot의 packageRoot fallback 안 탐
308
- const plan = planSync(targetRoot, cfg, opts.persona, opts.gitignore === true)
441
+ const plan = planSync(targetRoot, cfg, opts.persona, opts.gitignore === true, opts.personaApply === true)
442
+
443
+ // ── persona 차이의 실제 내용 diff — 🔴 **쓰기보다 먼저** 인쇄한다(REQ-2026-050 D5) ──
444
+ // dry-run에서도 낸다. 사용자가 적용 여부를 고르려면 적용 전에 봐야 한다.
445
+ if (plan.personaDiff) {
446
+ const d = plan.personaDiff
447
+ try {
448
+ for (const line of renderPersonaDiff(diffRunner(d.shippedAbs, d.targetAbs), d)) log(line)
449
+ } catch (err) {
450
+ // 🔴 diff 생산 실패 = 교체 금지(fail-closed). 근거를 보여줄 수 없으면 선택을 받을 수 없다.
451
+ const reason = err instanceof Error ? err.message : String(err)
452
+ log('')
453
+ log(` ⚠️ persona diff를 생성하지 못했습니다 — ${reason}`)
454
+ const dropped = plan.writes.length
455
+ plan.writes = plan.writes.filter((w) => w.destRel !== d.targetRel)
456
+ plan.backups = plan.backups.filter((b) => b.srcRel !== d.targetRel)
457
+ if (dropped !== plan.writes.length)
458
+ log(' ⛔ diff 없이는 교체하지 않습니다(fail-closed). persona는 미접촉으로 남깁니다.')
459
+ log('')
460
+ }
461
+ }
309
462
 
310
463
  if (opts.apply) {
464
+ // 백업이 writes보다 **먼저**다(D6). 실패하면 대응 write를 버리고 교체하지 않는다.
465
+ for (const b of plan.backups) {
466
+ const bakAbs = join(targetRoot, b.bakRel)
467
+ try {
468
+ statWritableDest(targetRoot, b.bakRel) // 백업 대상도 confinement 단일 경로를 탄다
469
+ mkdirSync(dirname(bakAbs), { recursive: true })
470
+ backupFile(join(targetRoot, b.srcRel), bakAbs)
471
+ log(` 🗂 백업: ${b.bakRel} (직전 1세대만 보존 — 기존 백업은 덮어씀)`)
472
+ } catch (err) {
473
+ const reason = err instanceof Error ? err.message : String(err)
474
+ log(` ⚠️ 백업 실패 — ${reason}`)
475
+ log(' ⛔ 백업 없이는 교체하지 않습니다(fail-closed).')
476
+ plan.writes = plan.writes.filter((w) => w.destRel !== b.srcRel)
477
+ }
478
+ }
311
479
  for (const w of plan.writes) {
312
480
  // 쓰기 직전 confinement 재검증(planSync와 apply 사이 TOCTOU 최소화 — 단일 경로 재사용).
313
481
  statWritableDest(targetRoot, w.destRel)
@@ -323,7 +491,13 @@ export function runSync(opts: SyncOptions): SyncPlan {
323
491
  }
324
492
  }
325
493
 
326
- for (const line of renderPlan(plan, opts.apply, opts.persona, opts.gitignore === true)) console.log(line)
494
+ for (const line of renderPlan(plan, opts.apply, opts.persona, opts.gitignore === true)) log(line)
495
+ if (plan.personaDiff && opts.personaApply !== true) {
496
+ log(' ℹ️ persona 차이는 기본적으로 교체하지 않습니다 — 위 diff를 확인한 뒤')
497
+ log(' `npx commitgate sync --apply --persona --persona-apply` 로만 교체됩니다(교체 전 .bak 백업).')
498
+ }
499
+ if (opts.personaApply === true && !opts.persona)
500
+ log(' ℹ️ --persona-apply 는 --persona 를 함의하지 않습니다 — persona 축은 미접촉입니다(둘을 함께 주십시오).')
327
501
  return plan
328
502
  }
329
503
 
@@ -333,6 +507,7 @@ export function parseArgs(argv: string[]): SyncOptions {
333
507
  let apply = false
334
508
  let persona = false
335
509
  let gitignore = false
510
+ let personaApply = false
336
511
  for (let i = 0; i < argv.length; i++) {
337
512
  const a = argv[i]
338
513
  if (a === '--dir') {
@@ -344,6 +519,8 @@ export function parseArgs(argv: string[]): SyncOptions {
344
519
  apply = true
345
520
  } else if (a === '--persona') {
346
521
  persona = true
522
+ } else if (a === '--persona-apply') {
523
+ personaApply = true
347
524
  } else if (a === '--gitignore') {
348
525
  gitignore = true
349
526
  } else if (a === '--dry-run') {
@@ -355,7 +532,7 @@ export function parseArgs(argv: string[]): SyncOptions {
355
532
  throw new Error(`알 수 없는 인자: ${a}`)
356
533
  }
357
534
  }
358
- return { dir: resolve(dir), apply, persona, gitignore }
535
+ return { dir: resolve(dir), apply, persona, gitignore, personaApply }
359
536
  }
360
537
 
361
538
  function printHelp(): void {
@@ -364,12 +541,18 @@ function printHelp(): void {
364
541
  사용법:
365
542
  npx commitgate sync [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
366
543
  npx commitgate sync --apply [--dir <대상repo>] 스키마 축 재동기화
367
- npx commitgate sync --apply --persona 스키마 + 페르소나(부재 복원만) 재동기화
544
+ npx commitgate sync --apply --persona 스키마 + 페르소나(부재 복원 · 차이 시 diff 표시) 재동기화
545
+ npx commitgate sync --apply --persona --persona-apply 위 + 페르소나 차이를 shipped 로 교체(.bak 백업 후)
368
546
  npx commitgate sync --apply --gitignore 스키마 + workflow/.gitignore 누락 kit 규칙 보강
369
547
 
370
548
  하는 일:
371
549
  workflow/machine.schema.json · workflow/req.config.schema.json 을 설치된 패키지 사본으로 되돌립니다.
372
- --persona: 페르소나가 부재면 복원합니다. 내용이 다르면(사용자 편집 가능성) 덮지 않고 보존만 합니다.
550
+ --persona: 페르소나가 부재면 복원합니다. 내용이 다르면 **적용 전에 실제 내용 diff 를 출력**하고
551
+ 기본적으로는 덮지 않습니다(dry-run 에서도 diff 를 봅니다).
552
+ --persona-apply: 위 diff 를 확인한 뒤 교체하려면 --persona 와 **함께** 지정합니다.
553
+ 교체 전에 workflow/review-persona.md.bak 을 남깁니다(직전 1세대). 백업이나 diff 생성이
554
+ 실패하면 교체하지 않습니다(fail-closed). 마커가 없는 페르소나는 직접 작성분일 수 있어
555
+ 경고를 덧붙이지만, 교체 경로 자체는 동일합니다 — 판단은 사용자가 합니다.
373
556
  --gitignore: workflow/.gitignore 에 없는 kit 규칙 행만 말미에 추가합니다(기존 행은 미변경·미재정렬).
374
557
  파일이 없으면 kit 템플릿 전체로 생성합니다. 이미 있는 규칙은 건너뜁니다(멱등).
375
558
  0.9.6 이하 설치본은 review-call 로그 규칙이 없어 첫 리뷰 뒤 D10 이 커밋을 막습니다 — 이 옵션이 그 백필입니다.