commitgate 0.9.6 → 0.9.9

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/bin/sync.ts CHANGED
@@ -11,11 +11,19 @@
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
- * 하는 일: companion skills·workflow/.gitignore·package.json·req:*·req.config.json·에이전트 진입점 미접촉.
20
+ * - **`workflow/.gitignore`(`--gitignore` opt-in, REQ-2026-047)**: 덮어쓰기 0건. kit 템플릿 규칙 중 **없는 행만**
21
+ * 말미에 append한다(기존 행 미변경·미재정렬·미삭제). 파일 부재면 템플릿 전체로 생성(= 전 규칙 누락의 경계 사례).
22
+ * 0.9.6 이하 설치본에는 review-call 로그 규칙이 없어 첫 리뷰 뒤 D10이 커밋을 막는다 — 그 백필 경로다.
23
+ * 존재 판정은 Git 의미론을 보존한다(앞 공백은 패턴의 일부 — `normalizeIgnoreLine`).
24
+ *
25
+ * 안 하는 일: companion skills·package.json·req:*·req.config.json·에이전트 진입점 미접촉.
26
+ * `workflow/.gitignore`도 `--gitignore` 없이는 완전 미접촉(**기본 동작 불변**).
19
27
  * 캐럿 범위(`^0.x`)는 소비자 package.json에서 PM이 강제하므로 코드로 못 고친다 — 문서(업그레이드 절)가 안내.
20
28
  *
21
29
  * ⚠️ **confinement는 재구현하지 않는다.** 모든 쓰기가 `statWritableDest`(bin/init.ts) 단일 경로를 탄다 —
@@ -27,11 +35,20 @@
27
35
  * ⚠️ **동기 구현이어야 한다.** launcher(bin/commitgate.mjs)가 `mod.runCli(rest)`를 await 없이 호출한다 —
28
36
  * async면 promise가 버려져 exit code가 소실된다(migrate.ts:18-19와 동일).
29
37
  */
30
- import { existsSync, copyFileSync, mkdirSync, realpathSync } from 'node:fs'
38
+ import { existsSync, copyFileSync, mkdirSync, realpathSync, readFileSync, writeFileSync } from 'node:fs'
31
39
  import { resolve, join, dirname, relative } from 'node:path'
32
40
  import { pathToFileURL } from 'node:url'
33
41
  import { loadConfig, DEFAULT_REVIEW_PERSONA_RELPATH, type ResolvedConfig } from '../scripts/req/lib/config'
34
- import { PACKAGE_ROOT, KIT_SCHEMA_RELPATHS, statWritableDest, sha256File, assertGitWorkTree } from './init'
42
+ import { safeSpawnSyncStatus } from '../scripts/req/lib/adapters'
43
+ import {
44
+ PACKAGE_ROOT,
45
+ KIT_SCHEMA_RELPATHS,
46
+ KIT_GITIGNORE,
47
+ kitGitignoreRules,
48
+ statWritableDest,
49
+ sha256File,
50
+ assertGitWorkTree,
51
+ } from './init'
35
52
 
36
53
  export interface SyncOptions {
37
54
  dir: string
@@ -39,6 +56,16 @@ export interface SyncOptions {
39
56
  apply: boolean
40
57
  /** 페르소나 처리 opt-in. 없으면 페르소나는 완전 미접촉. */
41
58
  persona: boolean
59
+ /**
60
+ * `workflow/.gitignore` 규칙 보강 opt-in(REQ-2026-047). 없으면 완전 미접촉 — **기본 동작 불변**.
61
+ * 생략 가능: 기존 호출부(3필드)를 깨지 않는다.
62
+ */
63
+ gitignore?: boolean
64
+ /**
65
+ * persona가 shipped와 다를 때 **교체**를 허용하는 opt-in(REQ-2026-050 D7). 없으면 기본 동작 그대로 미접촉.
66
+ * 🔴 `persona`를 **함의하지 않는다** — 둘을 함께 줘야 한다(우발적 교체를 막는 의도적 중복).
67
+ */
68
+ personaApply?: boolean
42
69
  }
43
70
 
44
71
  /**
@@ -46,23 +73,111 @@ export interface SyncOptions {
46
73
  * - `new`: dest 부재 → 복사(스키마/페르소나 공통, 잃을 것 없음).
47
74
  * - `in-sync`: sha 동일 → skip.
48
75
  * - `stale`: 스키마가 shipped와 다름 → 덮음(계약 = --force 축).
49
- * - `preserved-differs`: 기본 경로 페르소나가 shipped와 다름**미접촉**(사용자 편집 보존, report-only).
50
- * - `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 → 미접촉(교체 경로 없음).
51
80
  */
52
- export type AssetStatus = 'new' | 'in-sync' | 'stale' | 'preserved-differs' | 'unmanaged-custom' | 'unmanaged-null'
81
+ export type AssetStatus =
82
+ | 'new'
83
+ | 'in-sync'
84
+ | 'stale'
85
+ /** persona 축: kit 마커 有 · shipped와 다름 (REQ-2026-050 D4). 기본 미접촉, `--persona-apply`로 교체 가능. */
86
+ | 'managed-drift'
87
+ /** persona 축: kit 마커 無 · shipped와 다름. `managed-drift`와 동일 경로지만 **경고가 강하다**(사용자 작성분일 수 있음). */
88
+ | 'preserved-differs'
89
+ | 'unmanaged-custom'
90
+ | 'unmanaged-null'
91
+ /** gitignore 축: 파일은 있으나 kit 규칙 일부가 없음 → **누락 행만** 말미에 append(REQ-2026-047). */
92
+ | 'rules-missing'
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
+ }
53
102
 
54
103
  export interface AssetPlan {
55
104
  rel: string // 대상-상대 경로(표시용)
56
- axis: 'schema' | 'persona'
105
+ axis: 'schema' | 'persona' | 'gitignore'
57
106
  status: AssetStatus
58
107
  note?: string
59
108
  }
60
109
 
110
+ /** `workflow/.gitignore`에 덧붙일 누락 kit 규칙(REQ-2026-047). 기존 행은 건드리지 않는다. */
111
+ export interface GitignoreAppend {
112
+ destRel: string
113
+ /** kit 원문 그대로의 규칙 행들(순서 보존). */
114
+ missing: string[]
115
+ }
116
+
117
+ /** persona 교체 직전 원본을 보존하는 백업(REQ-2026-050 D6). 실패하면 교체하지 않는다. */
118
+ export interface PersonaBackup {
119
+ /** 백업 원본(대상 repo의 현재 persona) — 대상-상대. */
120
+ srcRel: string
121
+ /** 백업 대상 — 대상-상대. 직전 1세대만 보장(기존 파일은 덮어쓴다). */
122
+ bakRel: string
123
+ }
124
+
61
125
  export interface SyncPlan {
62
126
  targetRoot: string
63
127
  assets: AssetPlan[]
64
- /** apply 시 실제 복사할 항목(스키마 new/stale + 페르소나 부재복원만). */
128
+ /** apply 시 실제 복사할 항목(스키마 new/stale + 페르소나 부재복원 + gitignore 파일 부재 시 템플릿 전체). */
65
129
  writes: { srcAbs: string; destRel: string }[]
130
+ /** apply 시 **행 단위 append**할 항목(gitignore 축 전용). 복사가 아니라 추가라 writes와 분리한다. */
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
142
+ }
143
+
144
+ /**
145
+ * gitignore 행 정규화(존재 판정용) — 🔴 **Git ignore 의미론을 보존한다**(design r01 P1).
146
+ *
147
+ * gitignore(5): **후행 공백은 무시되지만(백슬래시로 이스케이프한 경우 제외) 앞 공백은 패턴의 일부**다.
148
+ * 따라서 후행 `\r`과 후행 공백만 제거하고 **앞 공백은 보존**한다.
149
+ *
150
+ * 결과적으로 ` /.review-calls.jsonl`(앞 공백)은 kit 규칙과 **다른 패턴**으로 판정되어 누락 취급되고,
151
+ * 정확한 규칙이 append된다. 반대로 트림 비교를 쓰면 "이미 있다"고 오판해 append를 건너뛰지만 Git은
152
+ * 그 파일을 무시하지 않아 다음 review 뒤 **D10 FAIL(P0)이 재발**한다.
153
+ *
154
+ * 방향은 **fail-safe**: 과(過)append는 무해(정확한 규칙이 추가되어 ignore가 성립)하고, 미(未)append는 P0 재발이다.
155
+ */
156
+ export function normalizeIgnoreLine(line: string): string {
157
+ // 후행 공백 제거 — 단 `\ `처럼 백슬래시로 이스케이프된 공백은 패턴의 일부라 보존한다.
158
+ return line.replace(/\r+$/, '').replace(/(?<!\\)[ \t]+$/, '')
159
+ }
160
+
161
+ /**
162
+ * 대상 `workflow/.gitignore` 본문에 없는 kit 규칙 행 목록(순수 — 테스트가 직접 구동한다).
163
+ *
164
+ * 비교는 `normalizeIgnoreLine`으로 하되 **kit 규칙 원문을 그대로 반환**한다(append되는 것은 정확한 kit 형태).
165
+ * 주석·빈 줄은 kit 규칙 목록에 없으므로(`kitGitignoreRules`) 자연히 제외된다.
166
+ */
167
+ export function missingKitIgnoreRules(existingContent: string, kitRules: readonly string[]): string[] {
168
+ const present = new Set(existingContent.split('\n').map(normalizeIgnoreLine))
169
+ return kitRules.filter((r) => !present.has(normalizeIgnoreLine(r)))
170
+ }
171
+
172
+ /**
173
+ * 누락 규칙을 본문 말미에 덧붙인 새 본문(순수). 기존 행은 **한 글자도 바꾸지 않는다**.
174
+ * 원본의 개행 관례(CRLF/LF)를 따르고, 마지막 줄에 개행이 없으면 먼저 채운다.
175
+ */
176
+ export function appendIgnoreRules(existingContent: string, missing: readonly string[]): string {
177
+ if (missing.length === 0) return existingContent
178
+ const eol = existingContent.includes('\r\n') ? '\r\n' : '\n'
179
+ const needsLeadingEol = existingContent.length > 0 && !/\r?\n$/.test(existingContent)
180
+ return existingContent + (needsLeadingEol ? eol : '') + missing.join(eol) + eol
66
181
  }
67
182
 
68
183
  /** targetRoot·PACKAGE_ROOT 동일성 판정용 정규화(Windows 8.3·case·symlink 차이 흡수 — init.assertGitWorkTree와 동일 기법). */
@@ -78,9 +193,18 @@ function canonical(p: string): string {
78
193
  * 재동기화 계획 수립(순수 판정 — 쓰기 없음). statWritableDest로 confinement + leaf를 판정하고 sha로 멱등 skip.
79
194
  * `--persona` 없으면 페르소나는 계획에 넣지 않는다(완전 미접촉).
80
195
  */
81
- export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boolean): SyncPlan {
196
+ export function planSync(
197
+ targetRoot: string,
198
+ cfg: ResolvedConfig,
199
+ persona: boolean,
200
+ gitignore = false,
201
+ personaApply = false,
202
+ ): SyncPlan {
82
203
  const assets: AssetPlan[] = []
83
204
  const writes: { srcAbs: string; destRel: string }[] = []
205
+ const appends: GitignoreAppend[] = []
206
+ const backups: PersonaBackup[] = []
207
+ let personaDiff: SyncPlan['personaDiff'] = null
84
208
 
85
209
  // ── 스키마 축(무조건 재동기화 — 계약, --force 축) ──
86
210
  for (const rel of KIT_SCHEMA_RELPATHS) {
@@ -117,30 +241,137 @@ export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boole
117
241
  } else if (sha256File(srcAbs) === sha256File(defaultAbs)) {
118
242
  assets.push({ rel: personaRel, axis: 'persona', status: 'in-sync' })
119
243
  } else {
120
- // 🔴 다름 → 절대 덮지 않는다(사용자 편집 보존). 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'))
121
252
  assets.push({
122
253
  rel: personaRel,
123
254
  axis: 'persona',
124
- status: 'preserved-differs',
125
- 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 로 교체 가능',
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
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // ── workflow/.gitignore 규칙 보강(--gitignore opt-in — REQ-2026-047) ──
270
+ // 🔴 이 파일은 git 관례상 **사용자 소유**다(init D12: 부재 시에만 생성, --force로도 미덮어씀).
271
+ // 따라서 **additive append 전용** — 기존 행을 수정·삭제·재정렬하지 않는다. 덮어쓰기는 절대 없다.
272
+ if (gitignore) {
273
+ const rel = KIT_GITIGNORE.dest
274
+ const st = statWritableDest(targetRoot, rel) // confinement + leaf(symlink escape 거부)
275
+ if (st === null) {
276
+ // 파일 부재 = "모든 kit 규칙이 누락된 상태"의 경계 사례 → 템플릿 전체로 생성(init의 seed와 동일 산출물).
277
+ assets.push({ rel, axis: 'gitignore', status: 'new', note: '부재 — kit 템플릿 전체로 생성' })
278
+ writes.push({ srcAbs: join(PACKAGE_ROOT, KIT_GITIGNORE.src), destRel: rel })
279
+ } else {
280
+ const missing = missingKitIgnoreRules(readFileSync(join(targetRoot, rel), 'utf8'), kitGitignoreRules())
281
+ if (missing.length === 0) {
282
+ assets.push({ rel, axis: 'gitignore', status: 'in-sync', note: 'kit 규칙 전부 존재' })
283
+ } else {
284
+ assets.push({
285
+ rel,
286
+ axis: 'gitignore',
287
+ status: 'rules-missing',
288
+ note: `누락 ${missing.length}행 → 말미에 추가(기존 행 미변경): ${missing.join(' , ')}`,
126
289
  })
290
+ appends.push({ destRel: rel, missing })
127
291
  }
128
292
  }
129
293
  }
130
294
 
131
- return { targetRoot, assets, writes }
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
132
361
  }
133
362
 
134
363
  /** 계획을 사람이 읽는 줄 배열로. shell 연산자 미사용(Windows PowerShell/cmd 호환 — DEC-011-8). */
135
- export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean): string[] {
364
+ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean, gitignore = false): string[] {
136
365
  const L: string[] = []
137
366
  const GLYPH: Record<AssetStatus, string> = {
138
367
  new: '+',
139
368
  'in-sync': '=',
140
369
  stale: '~',
370
+ 'managed-drift': '!',
141
371
  'preserved-differs': '!',
142
372
  'unmanaged-custom': '·',
143
373
  'unmanaged-null': '·',
374
+ 'rules-missing': '+',
144
375
  }
145
376
  L.push('')
146
377
  L.push(`[commitgate sync] vendored 계약 재동기화 ${apply ? '(--apply: 파일을 씁니다)' : '계획 (dry-run — 아무것도 쓰지 않습니다)'}`)
@@ -154,17 +385,24 @@ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean): st
154
385
  L.push('')
155
386
  L.push(' ℹ️ 페르소나는 미포함(--persona 로 opt-in). 스키마 축만 처리했습니다.')
156
387
  }
388
+ if (!gitignore) {
389
+ L.push(' ℹ️ workflow/.gitignore 는 미포함(--gitignore 로 opt-in). 기존 동작은 그대로입니다.')
390
+ }
157
391
  L.push('')
392
+ // 변경 건수 = 파일 복사(writes) + 행 추가(appends). 둘 다 없으면 "변경 없음".
393
+ const changes = plan.writes.length + plan.appends.length
394
+ const optIn = `${persona ? ' --persona' : ''}${gitignore ? ' --gitignore' : ''}`
158
395
  if (!apply) {
159
- if (plan.writes.length > 0) {
160
- L.push(` 적용하려면: npx commitgate sync --apply${persona ? ' --persona' : ''}`)
161
- L.push(` (변경 예정 ${plan.writes.length}개. --apply 후 git diff 로 확인하고 스테이징·커밋하십시오.)`)
396
+ if (changes > 0) {
397
+ L.push(` 적용하려면: npx commitgate sync --apply${optIn}`)
398
+ L.push(` (변경 예정 ${changes}개. --apply 후 git diff 로 확인하고 스테이징·커밋하십시오.)`)
162
399
  } else {
163
400
  L.push(' 변경 없음 — 이미 동기화되어 있습니다.')
164
401
  }
165
- } else if (plan.writes.length > 0) {
166
- L.push(` ✅ ${plan.writes.length}개 파일 갱신. 다음: git diff 로 확인 후 커밋하십시오.`)
402
+ } else if (changes > 0) {
403
+ L.push(` ✅ ${changes}개 파일 갱신. 다음: git diff 로 확인 후 커밋하십시오.`)
167
404
  for (const w of plan.writes) L.push(` git add -- ${w.destRel}`)
405
+ for (const a of plan.appends) L.push(` git add -- ${a.destRel}`)
168
406
  } else {
169
407
  L.push(' 변경 없음 — 이미 동기화되어 있습니다(쓰기 0건).')
170
408
  }
@@ -175,9 +413,11 @@ const STATUS_LABEL: Record<AssetStatus, string> = {
175
413
  new: '부재 → 복원',
176
414
  'in-sync': '최신(변경 없음)',
177
415
  stale: 'stale → 갱신',
178
- 'preserved-differs': '차이 감지 → 보존(수동 확인)',
416
+ 'managed-drift': 'kit 계보 · 차이 감지 → 기본 보존(--persona-apply 로 교체 가능)',
417
+ 'preserved-differs': '마커 없음 · 차이 감지 → 기본 보존(--persona-apply 로 교체 가능 — 직접 작성분일 수 있음)',
179
418
  'unmanaged-custom': 'custom 경로(unmanaged)',
180
419
  'unmanaged-null': '비활성(unmanaged)',
420
+ 'rules-missing': 'kit 규칙 누락 → 말미에 추가(기존 행 미변경)',
181
421
  }
182
422
 
183
423
  /**
@@ -186,7 +426,11 @@ const STATUS_LABEL: Record<AssetStatus, string> = {
186
426
  * 🔴 packageRoot 가드: `targetRoot===PACKAGE_ROOT`면 어떤 쓰기 전에도 거부(fail-closed). CommitGate 패키지 자신을
187
427
  * 재작성하는 사고를 막는다. `loadConfig({root})` 명시로 resolveRoot fallback도 원천 차단(이중 방어).
188
428
  */
189
- 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
+
190
434
  const targetRoot = resolve(opts.dir)
191
435
  if (!existsSync(targetRoot)) throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
192
436
  assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
@@ -194,9 +438,44 @@ export function runSync(opts: SyncOptions): SyncPlan {
194
438
  throw new Error('sync 대상이 CommitGate 패키지 자신입니다 — 소비 repo(commitgate를 devDependency로 설치한 곳)에서 실행하세요.')
195
439
 
196
440
  const cfg = loadConfig({ root: targetRoot }) // root 명시 → resolveRoot의 packageRoot fallback 안 탐
197
- const plan = planSync(targetRoot, cfg, opts.persona)
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
+ }
198
462
 
199
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
+ }
200
479
  for (const w of plan.writes) {
201
480
  // 쓰기 직전 confinement 재검증(planSync와 apply 사이 TOCTOU 최소화 — 단일 경로 재사용).
202
481
  statWritableDest(targetRoot, w.destRel)
@@ -204,9 +483,21 @@ export function runSync(opts: SyncOptions): SyncPlan {
204
483
  mkdirSync(dirname(destAbs), { recursive: true })
205
484
  copyFileSync(w.srcAbs, destAbs)
206
485
  }
486
+ for (const a of plan.appends) {
487
+ // 동일하게 쓰기 직전 confinement 재검증. append는 **읽고-덧붙여-쓰기**이므로 원본을 다시 읽는다.
488
+ statWritableDest(targetRoot, a.destRel)
489
+ const destAbs = join(targetRoot, a.destRel)
490
+ writeFileSync(destAbs, appendIgnoreRules(readFileSync(destAbs, 'utf8'), a.missing), 'utf8')
491
+ }
207
492
  }
208
493
 
209
- for (const line of renderPlan(plan, opts.apply, opts.persona)) 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 축은 미접촉입니다(둘을 함께 주십시오).')
210
501
  return plan
211
502
  }
212
503
 
@@ -215,6 +506,8 @@ export function parseArgs(argv: string[]): SyncOptions {
215
506
  let dir = process.cwd()
216
507
  let apply = false
217
508
  let persona = false
509
+ let gitignore = false
510
+ let personaApply = false
218
511
  for (let i = 0; i < argv.length; i++) {
219
512
  const a = argv[i]
220
513
  if (a === '--dir') {
@@ -226,6 +519,10 @@ export function parseArgs(argv: string[]): SyncOptions {
226
519
  apply = true
227
520
  } else if (a === '--persona') {
228
521
  persona = true
522
+ } else if (a === '--persona-apply') {
523
+ personaApply = true
524
+ } else if (a === '--gitignore') {
525
+ gitignore = true
229
526
  } else if (a === '--dry-run') {
230
527
  apply = false // 기본값이지만 명시 허용
231
528
  } else if (a === '-h' || a === '--help') {
@@ -235,7 +532,7 @@ export function parseArgs(argv: string[]): SyncOptions {
235
532
  throw new Error(`알 수 없는 인자: ${a}`)
236
533
  }
237
534
  }
238
- return { dir: resolve(dir), apply, persona }
535
+ return { dir: resolve(dir), apply, persona, gitignore, personaApply }
239
536
  }
240
537
 
241
538
  function printHelp(): void {
@@ -244,14 +541,25 @@ function printHelp(): void {
244
541
  사용법:
245
542
  npx commitgate sync [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
246
543
  npx commitgate sync --apply [--dir <대상repo>] 스키마 축 재동기화
247
- npx commitgate sync --apply --persona 스키마 + 페르소나(부재 복원만) 재동기화
544
+ npx commitgate sync --apply --persona 스키마 + 페르소나(부재 복원 · 차이 시 diff 표시) 재동기화
545
+ npx commitgate sync --apply --persona --persona-apply 위 + 페르소나 차이를 shipped 로 교체(.bak 백업 후)
546
+ npx commitgate sync --apply --gitignore 스키마 + workflow/.gitignore 누락 kit 규칙 보강
248
547
 
249
548
  하는 일:
250
549
  workflow/machine.schema.json · workflow/req.config.schema.json 을 설치된 패키지 사본으로 되돌립니다.
251
- --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
+ 경고를 덧붙이지만, 교체 경로 자체는 동일합니다 — 판단은 사용자가 합니다.
556
+ --gitignore: workflow/.gitignore 에 없는 kit 규칙 행만 말미에 추가합니다(기존 행은 미변경·미재정렬).
557
+ 파일이 없으면 kit 템플릿 전체로 생성합니다. 이미 있는 규칙은 건너뜁니다(멱등).
558
+ 0.9.6 이하 설치본은 review-call 로그 규칙이 없어 첫 리뷰 뒤 D10 이 커밋을 막습니다 — 이 옵션이 그 백필입니다.
252
559
 
253
560
  하지 않는 일:
254
- companion skills · workflow/.gitignore · package.json · req:* · req.config.json 은 건드리지 않습니다.
561
+ companion skills · package.json · req:* · req.config.json 은 건드리지 않습니다.
562
+ workflow/.gitignore 는 --gitignore 를 명시할 때만 손대며, 그때도 **덮어쓰지 않고 누락 행만 추가**합니다.
255
563
  캐럿 범위(^0.x)는 자동으로 못 넘깁니다 — 업그레이드(0.x) 문서(github.com/sol5288/commitgate/blob/main/docs/upgrade.md)를 참고해 범위를 먼저 올리세요.
256
564
  `)
257
565
  }
package/bin/uninstall.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  KIT_CLAUDE_DEST_REL,
36
36
  KIT_AGENTS_CONTRACT_COPY_REL,
37
37
  REQ_SCRIPTS,
38
+ STAGE_B_REQ_SCRIPTS,
38
39
  REQ_DEV_DEPS,
39
40
  assertGitWorkTree,
40
41
  } from './init'
@@ -304,13 +305,18 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
304
305
  const pkg = existsSync(pkgAbs) ? readJsonObject(pkgAbs) : null
305
306
  const scripts = stringMap(pkg, 'scripts')
306
307
  const devDeps = stringMap(pkg, 'devDependencies')
307
- for (const [k, injected] of Object.entries(REQ_SCRIPTS)) {
308
+ // 🔴 DEC-D3: req:* script를 **Stage-A 서명(REQ_SCRIPTS) ∪ Stage-B 값(STAGE_B_REQ_SCRIPTS)** 양쪽 기준으로
309
+ // 분류한다. 현재 값이 둘 중 하나와 일치하면 CommitGate 주입값(제거 대상 표시), 아니면 사용자 정의(보존).
310
+ // Stage-B 표면(dispatch 파생)이라 reconstruct 등 신규 verb도 포함된다.
311
+ const reqKeys = [...new Set([...Object.keys(REQ_SCRIPTS), ...Object.keys(STAGE_B_REQ_SCRIPTS)])].sort()
312
+ for (const k of reqKeys) {
308
313
  const cur = scripts[k]
309
314
  if (cur === undefined) continue
315
+ const isInjected = cur === REQ_SCRIPTS[k] || cur === STAGE_B_REQ_SCRIPTS[k]
310
316
  ambiguous.push({
311
317
  path: `package.json#scripts.${k}`,
312
318
  present: true,
313
- note: cur === injected ? 'init 주입값과 동일' : `사용자 값(init 주입값과 다름): ${cur}`,
319
+ note: isInjected ? 'init 주입값과 동일' : `사용자 값(init 주입값과 다름): ${cur}`,
314
320
  })
315
321
  }
316
322
  for (const [k, injected] of Object.entries(REQ_DEV_DEPS)) {