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/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  이 프로젝트는 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
4
4
 
5
+ ## 0.9.9
6
+
7
+ **리뷰 게이트 운영 보강 4종** (REQ-2026-053~056, 소비 저장소 운영감사 후속). 모두 0.9.8 위의 **추가 기능**(신규 명령·additive 필드·opt-in)이라 기존 사용자는 무회귀입니다.
8
+
9
+ - **레거시 완료 티켓 마이그레이션 종결 — `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 기본·재실행 멱등.
10
+
11
+ - **리뷰 호출 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` 별도 카운터**로 예산이 보는 유효 회차를 낮춥니다.
12
+
13
+ - **`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를 기록해, 부분 실패 시 근거 없는 예외가 소비되지 않습니다. 소비 로직·예산 게이트는 무변경.
14
+
15
+ - **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(공백) 경로까지 처리합니다.
16
+
17
+ 전체 테스트 1363 → 1709.
18
+
19
+ ## 0.9.8
20
+
21
+ **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 커밋 + 병합"에 도달해도 **설계 승인 증거가 커밋 이력에 전혀 남지 않을 수 있었고 아무 게이트도 불평하지 않았습니다** — 소비자 저장소에서 실측된 사고입니다.
22
+
23
+ 이제 성공한 `req:review-codex --kind design --run`이 **승인 아카이브·needs-fix 라운드·매니페스트를 그 자리에서 커밋**합니다. 운영자가 별도 명령을 기억할 필요가 없습니다. `--finalize-design`은 제거하지 않고 **멱등 복구 경로**로 남아 같은 구현을 호출하므로 두 경로의 동작이 갈라질 수 없습니다. 멱등 판정은 온디스크가 아니라 **`HEAD` 기준**입니다 — 매니페스트 기록·stage까지 되고 커밋만 실패한 부분 상태에서 재시도가 영구히 skip되어 증거를 복구하지 못하던 함정을 없앴습니다. 커밋 실패는 **승인 판정이나 종료 코드를 바꾸지 않고**(기록 실패가 게이트 결정을 뒤집으면 계약 위반입니다) 복구 명령을 안내합니다. 커밋은 **pathspec 범위**라 설계 문서를 stage한 채 승인하는 정상 경로에서도 무관한 staged 변경이 섞이지 않고 index에 그대로 남습니다.
24
+
25
+ design 매니페스트 행에 **`archive_inventory`**(각 아카이브의 경로·SHA-256)를 추가해 그 승인에 이르는 **모든 라운드**를 함께 영속화합니다. 목록은 승인 시점 티켓 `responses/` 직계의 design 아카이브 전부를 라운드 오름차순으로 담아 디렉터리 읽기 순서에 비의존이며, 파일명 sweep과 달리 사후 감사에서 **재검증 가능**합니다. 선택 필드라 기존 매니페스트는 그대로 유효합니다.
26
+
27
+ **`req:next`가 완료를 선언하기 직전** `HEAD`의 Git blob에서 매니페스트 design 행·아카이브·SHA를 검증하고, 미완이면 `DONE` 대신 **`BLOCKED`와 복구 명령**을 반환합니다. 판별 marker(`evidence_durability_required`, `req:new`가 스캐폴드에 심음)도 **커밋된 blob**에서 읽어 캐시 소실로 우회되지 않습니다. 🔴 이 검사는 **`req:next`의 완료 판정에서만** fail-closed입니다 — `req:doctor`·일반 `req:commit`에는 넣지 않았습니다(doctor는 `req:commit`의 하드 게이트라 FAIL이면 기존 소비자의 모든 커밋이 벽돌이 됩니다). **0.9.8 이전에 만들어진 티켓은 검사 대상이 아니며 기존 DONE 동작을 유지합니다.**
28
+
29
+ 내부적으로는 매니페스트 모델·검증을 leaf 모듈 `scripts/req/lib/evidence.ts`로 추출해 `review-codex`↔`req-commit` 런타임 순환 없이 두 경로가 같은 구현을 공유하게 했고(그 순환이 흡수를 막던 구조적 원인입니다), 그 leaf 불변식을 테스트로 고정했습니다.
30
+
31
+ **DONE 게이트가 실제로 검증하는 것**(REQ-2026-049에서 fail-closed로 보강): `HEAD`의 Git blob만 보고 ① 커밋된 `state.json`이 해석 가능한지(부재·파손·`phases` 비배열이면 BLOCKED) ② 커밋된 `approvals.jsonl` 전체가 매니페스트 검증(스키마·경로 confinement·`-approved.json` 파일명·SHA 형식·예상 외 필드·중복/주입)을 통과하는지 ③ design 행의 `response_sha256`이 **HEAD blob의 SHA와 일치**하는지(존재 확인이 아니라 대조) ④ `archive_inventory`가 **비어 있지 않고** 승인 아카이브를 정확한 SHA로 포함하는지 ⑤ 인벤토리가 **HEAD에 있는 그 티켓 design 아카이브 전체 집합과 정확히 일치**하는지(빠짐·잉여 모두 거부) ⑥ 각 인벤토리 항목의 SHA가 HEAD blob과 일치하는지를 확인합니다. 초기 구현은 존재만 확인하고 빈 인벤토리를 통과시켜, 손상된 커밋 매니페스트가 완료 판정을 통과할 수 있었습니다.
32
+
33
+ > 한 가지 예외가 있습니다: **phase 행의 `phase_id` 멤버십은 이 게이트가 검사하지 않습니다.** `state.json`은 설계상 스캐폴드 이후 재커밋되지 않아 `HEAD`의 `phases`가 항상 비어 있기 때문입니다. 그 바인딩은 커밋 시점에 `req:commit`의 evidence preflight가 이미 강제합니다.
34
+
35
+ 테스트 환경은 **global/system git config와 `EMAIL` 등 환경 유래 identity를 차단**합니다. 그러지 않으면 저장소-local identity를 빠뜨린 fixture가 개발자 머신의 전역 설정에 가려 **CI에서만 실패**합니다(실제로 그렇게 됐습니다). 전체 테스트 1306 → 1363.
36
+
37
+ ## 0.9.7
38
+
39
+ **소비자 저장소에서 review-call 측정 로그가 커밋을 막던 P0 수정 + 기존 설치본 백필** (REQ-2026-047). `req:review-codex`가 소비 저장소 루트에 남기는 측정 로그(`workflow/.review-calls.jsonl`)의 무시 규칙이 **배포 템플릿 `templates/workflow.gitignore`에 누락**돼 있었습니다(개발 저장소 자신의 루트 `.gitignore`에만 있었고, npm은 `.gitignore` 이름을 tarball에서 제외하므로 소비자에게 전달되지 않습니다). 그 결과 `commitgate init`한 저장소에서 리뷰를 한 번이라도 돌리면 로그가 untracked로 남아 **`req:doctor` D10이 FAIL하고 `req:commit`이 모든 커밋을 차단**했습니다. 템플릿에 앵커형 `/.review-calls.jsonl`을 추가해 **신규 설치는 즉시 해소**되고, 회귀는 문자열 비교가 아니라 **packed tarball → 실제 `init` → `git check-ignore -v`(매칭 출처까지 단언)** 로 `scripts/smoke.mjs`에 고정했습니다.
40
+
41
+ `workflow/.gitignore`는 seed-once(부재 시에만 생성, `--force`로도 미덮음)라 템플릿 수정만으로는 기존 설치본이 구제되지 않으므로, 명시적 opt-in **`commitgate sync --gitignore [--apply]`** 를 추가했습니다 — 누락된 kit 규칙 **행만 말미에 추가**하고 기존 행은 변경·삭제·재정렬하지 않으며, 파일이 없으면 템플릿 전체로 생성합니다. 존재 판정은 **Git ignore 의미론을 보존**해 후행 공백·CR만 무시하고 **앞 공백은 패턴의 일부로 취급**합니다(` /.review-calls.jsonl`처럼 실제로는 무시되지 않는 행을 "이미 있음"으로 오판해 백필을 건너뛰지 않도록). **`sync` 기본 동작은 불변**이라 `--gitignore` 없이는 이 파일을 전혀 건드리지 않습니다.
42
+
43
+ 진단으로 **`req:doctor` D22**를 추가했습니다 — repo-root 런타임 스크래치가 ignore도 tracked도 아니면 "다음 review 뒤 D10이 커밋을 막는다"를 알리고 백필 명령을 안내합니다. **WARN 상한이며 절대 FAIL이 아닙니다**(doctor는 `req:commit`의 하드 게이트라 FAIL이면 소비자 커밋이 벽돌이 됩니다). D10의 스크래치 의미론(`reviewScratchPaths`)은 **의도적으로 무변경**입니다 — 로그를 스크래치 허용목록에 넣으면 배포 ignore 누락 자체를 D10이 숨기게 됩니다. 런타임 생성 파일 인벤토리 표와 이미 커밋해 tracked가 된 경우의 복구(`git rm --cached`) 절차는 [문제 해결](https://github.com/sol5288/commitgate/blob/main/docs/troubleshooting.md)에 정리했습니다.
44
+
5
45
  ## 0.9.6
6
46
 
7
47
  **Claude Code용 품질 오버레이 companion skill `commitgate-quality` 추가** (REQ-2026-044). 기존 4종에 이어 5번째 companion skill을 같은 안전한 설치 경로(seed-once·`--force` 미덮음·confinement·`--no-agent-entrypoints` opt-out·uninstall)로 번들·설치합니다. 이 스킬은 Superpowers 방법론의 장점(요구 정제·설계/계획 품질·Test-First·증거 기반 검증)만 **협조적 지침**으로 흡수하며, Superpowers 플러그인·런타임은 설치·실행·의존하지 않습니다. 정본(SSOT) 비복제·설계 품질·계획 품질은 자체 소유하고, Test-First·버그 진단·요구 정제는 형제 스킬(`commitgate-tdd`·`commitgate-diagnosing-bugs`·`commitgate-discovery`)을 가리켜 내부 중복을 피합니다. 새 설치의 `CLAUDE.md`에 발견 포인터 1줄을 추가하되 계약 정본(`AGENTS.md`)은 불변입니다. **강제는 CommitGate 실행 게이트가 담당하며 이 스킬은 방법일 뿐**입니다 — `req:next`의 행동 계산, 리뷰·승인 판정, `state.json`/`responses/`, 커밋 권한을 침범하지 않습니다.
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
  /**
@@ -564,7 +577,7 @@ export function sha256File(abs: string): string {
564
577
  }
565
578
 
566
579
  /** kit `workflow/.gitignore` 템플릿의 규칙 라인(주석·빈 줄 제외). differs WARN에서 사용자가 병합할 실제 규칙을 보여 준다. */
567
- function kitGitignoreRules(): string[] {
580
+ export function kitGitignoreRules(): string[] {
568
581
  return readFileSync(join(PACKAGE_ROOT, KIT_GITIGNORE.src), 'utf8')
569
582
  .split('\n')
570
583
  .map((l) => l.trim())
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
  }