commitgate 0.8.1 → 0.9.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.
@@ -33,6 +33,11 @@
33
33
  - 각 phase는 의미 있는 커밋. "WIP" 금지.
34
34
  - 커밋 메시지 컨벤션: `test/feat/fix/refactor/docs/chore` 접두사. `[Codex]`/`[Claude]` 같은 메타 정보 금지(Reviewer 편향 방지).
35
35
  - HIGH 영향 phase는 `req:commit --run` 직전 사용자 확인(`state.user_commit_confirmed`).
36
+ - **LOW phase 자동 커밋(opt-in)**: `req.config.json`의 `phaseCommit.autoApprove`가 `low-only`이고 `risk_level`이
37
+ `LOW`(정확 일치)면, Codex 승인 phase를 사람 정지 없이 자동 커밋한다(`req:next`가 `req:commit --run`을 RUN으로 지시).
38
+ 기본값 `never`면 종전대로 매 phase 사람 확인. **HIGH는 정책과 무관하게 매 phase 확인**(`userConfirmGate` 백스톱).
39
+ 자동 커밋을 켜면 사람 확인은 feature→main **통합 통제점**(I1/I2/B1)으로 모인다 — `req:next` 종단이 `DONE`이 아니라
40
+ `AWAIT_HUMAN`(통합)으로 멈춘다.
36
41
 
37
42
  ### 5. 승인 범위 해석 규칙
38
43
 
package/CHANGELOG.md CHANGED
@@ -2,7 +2,34 @@
2
2
 
3
3
  이 프로젝트는 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
4
4
 
5
- ## Unreleased
5
+ ## 0.9.0
6
+
7
+ phase 자동 커밋 opt-in(REQ-2026-037)과 업그레이드 자산 skew 감지·복구(REQ-2026-038)가 핵심입니다. 둘 다 0.8.x 위
8
+ **추가 기능**(opt-in·additive·backward-compatible)이라 기존 사용자는 무회귀입니다. 업그레이드는 `npm install -D commitgate@latest`
9
+ 후 `commitgate sync --apply`로 vendored 자산을 맞추세요(README "업그레이드 (0.x)" 절).
10
+
11
+ - **자산 skew 감지·복구 — `commitgate sync` + doctor D20** (REQ-2026-038). 소비 프로젝트가 런타임을 minor 넘어
12
+ 업그레이드할 때의 두 함정을 닫습니다. **(1) 캐럿 범위**: `^0.y`는 0.x minor를 자동으로 넘지 않아(`npm update`가
13
+ 0.7.x에 머묾) 범위를 명시적으로 올려야 합니다 — README에 "업그레이드 (0.x)" 절을 신설하고, "업데이트는 한 번"이라던
14
+ 기존 오도 문구(한/영)를 교정했습니다. **(2) vendored 자산 skew**: 런타임은 스키마·persona를 소비 repo의 사본에서
15
+ 읽는데 `npm update`는 그 사본을 갱신하지 않아, 새 런타임이 옛 계약을 읽어 신규 필드(`full_review_requested`)가
16
+ 조용히 죽습니다(`machine_schema_version`이 minor 간 불변이라 버전으로는 감지 불가 — **content-hash로만** 잡힘).
17
+ 신규 **`commitgate sync`**(기본 dry-run·`--apply`·`--persona`)가 vendored 스키마 축을 설치된 패키지 사본으로
18
+ 되돌리고(모든 쓰기는 init의 confinement 경로 재사용, `targetRoot===패키지 루트`면 하드 거부), 페르소나는 opt-in에서
19
+ **부재 복원만**(사용자 수정본 불가침). **`req:doctor` D20**이 vendored 스키마가 설치 사본과 어긋나면 WARN합니다
20
+ (**절대 FAIL 아님** — 커밋 게이트를 벽돌로 만들지 않음). SSOT 갭 **G-10**·로드맵 **STR-06**을 MVP(manifest-free
21
+ content-oracle) 범위로 부분 해결했습니다(커밋 install 원장·persona 3-way·rollback은 후속).
22
+
23
+ - **phase 자동 커밋(opt-in) — `phaseCommit.autoApprove`** (REQ-2026-037). `req.config.json`에
24
+ `"phaseCommit": { "autoApprove": "low-only" }`를 두면 **LOW 위험** 티켓의 Codex 승인 phase가 사람 정지 없이
25
+ 자동 커밋되고(`req:next`가 `req:commit --run`을 RUN으로 지시), 사람 확인은 feature→main **병합 직전 한 번**으로
26
+ 모입니다(종단이 `DONE` 대신 `AWAIT_HUMAN`(통합)). **기본값 `never`는 현행 동작(매 phase 확인)과 100% 동일**해
27
+ 기존 사용자는 무회귀입니다. **HIGH 티켓은 정책과 무관하게 매 phase 확인**(`userConfirmGate` 백스톱)이고,
28
+ fail-closed로 `risk_level`이 정확히 `LOW`일 때만 자동입니다(누락·불명·`"all"` 정책은 없음 — HIGH livelock 방지).
29
+ Codex 리뷰 게이트·커밋시점 doctor 재검증은 무변경 — 제거되는 것은 LOW phase의 *사람 정지*뿐입니다.
30
+ (런타임은 이미 구현·커밋돼 있습니다: 설정 배선·enum은 [`scripts/req/lib/config.ts`](scripts/req/lib/config.ts)의
31
+ `phaseCommit`/`CONFIG_SCHEMA`, 자동 커밋 분기·복구 가드·병합 게이트는 [`scripts/req/req-next.ts`](scripts/req/req-next.ts)의
32
+ `resolveNext`. 이 문서 변경은 그 검증된 동작을 문서·기본 설정에 반영한 것입니다.)
6
33
 
7
34
  ## 0.8.1
8
35
 
package/README.en.md CHANGED
@@ -37,7 +37,7 @@ codex login status
37
37
  ```
38
38
 
39
39
  > **Why two steps?** CommitGate does **not** copy its runtime code into your project. Step 1 puts the runtime in `node_modules/commitgate`; step 2 adds only **governance assets** (config, contract, schemas, persona) plus `req:* = commitgate <verb>` scripts.
40
- > That means updating is a single `npm update commitgate`, and removing the runtime is `npm uninstall -D commitgate`.
40
+ > Removing the runtime is a single `npm uninstall -D commitgate`. **For updates, follow the [Upgrading (0.x)](#upgrading-0x) section below** — the runtime (`node_modules`) is bumped with `npm`, but the vendored assets (schemas, persona) in your project must be re-synced separately with `commitgate sync`, and a 0.x caret range (`^0.y`) does not cross a minor automatically.
41
41
  > `init` **stops** if `devDependencies.commitgate` is not declared — there would be no runtime for `req:*` to point at.
42
42
 
43
43
  Installation writes files but never commits them. `req:new` **requires a clean working tree**, so commit the scaffold first. The installer's `다음:` (next steps) output prints the exact paths to stage.
@@ -113,6 +113,13 @@ Use `--json` for machine-readable output. It is **read-only** and changes no sta
113
113
 
114
114
  Repeat this loop without stopping and it drives design → Codex review → implementation → re-review → commit. You only confirm at `AWAIT_HUMAN`.
115
115
 
116
+ > **Per-phase auto-commit (opt-in).** By default the loop stops at `AWAIT_HUMAN` before every phase commit. Set
117
+ > `"phaseCommit": { "autoApprove": "low-only" }` in `req.config.json` and Codex-approved phases of **LOW-risk**
118
+ > tickets commit without a human stop (`req:next` issues `req:commit --run` as a RUN), moving the single human
119
+ > confirmation to just **before the feature→main merge** (the terminal becomes `AWAIT_HUMAN` (integration) instead
120
+ > of `DONE`). **HIGH-risk tickets still stop at every phase** regardless of the policy. The Codex review gate is
121
+ > unchanged either way — only the *human stop* on LOW phases is removed.
122
+
116
123
  ### The reviewer persona is injected by the tool
117
124
 
118
125
  `req:review-codex` puts `workflow/review-persona.md` in as the **first block** of the prompt. It is identical whether a human, Cursor, or Claude runs the command — it does not live where an agent can forget it. If the file is missing or empty, the review stops fail-closed.
@@ -228,7 +235,7 @@ runtime — these are pinned copies inside the package. Each SKILL.md carries th
228
235
  | `scripts/req/**` runtime code | `node_modules/commitgate` — never copied into your project |
229
236
  | `tsx` · `ajv` · `cross-spawn` | runtime dependencies of the `commitgate` package — never injected into your `package.json` |
230
237
 
231
- What stays in your project is **governance and audit data** only: config, contract, schemas, persona, and `workflow/REQ-*` evidence. Because the runtime lives in the package, `npm update commitgate` updates it in one step and vendored copies cannot drift.
238
+ What stays in your project is **governance and audit data** only: config, contract, schemas, persona, and `workflow/REQ-*` evidence. The **runtime code** lives in the package, so `npm update commitgate` refreshes it with no drift. But the **vendored assets** (schemas, persona) are separate from the runtime — on a minor upgrade you must also run `commitgate sync` (see [Upgrading (0.x)](#upgrading-0x)) or the runtime and assets will drift apart.
232
239
 
233
240
  The `req:*` scripts call the installed package bin — `npm run req:new -- <slug>` → `commitgate req:new <slug>` → `node_modules/.bin/commitgate`.
234
241
 
@@ -262,6 +269,43 @@ CommitGate stops **before writing any file** if a contract pointer would be swal
262
269
 
263
270
  ---
264
271
 
272
+ ## Upgrading (0.x)
273
+
274
+ Bumping the runtime takes **two** steps — `npm update` alone is not enough.
275
+
276
+ **① The caret range blocks 0.x minors.** `npm install -D commitgate` writes a `^0.y.z` range. In npm semver,
277
+ `^0.7.0` means `>=0.7.0 <0.8.0`, so `npm update`/`pnpm update` **will not cross a 0.x minor** (it stays within 0.7.x).
278
+ To cross a minor, raise the range explicitly:
279
+
280
+ ```sh
281
+ npm install -D commitgate@latest # or a specific version: commitgate@^0.8.0
282
+ ```
283
+
284
+ **② Vendored assets update separately from the runtime.** The command above refreshes the runtime
285
+ (`node_modules/commitgate`), but the contract assets in your project's `workflow/`
286
+ (`machine.schema.json`, `req.config.schema.json`) **stay as they were**. If you bump the runtime but leave the
287
+ assets, the new runtime **reads the old contract**, and newer features (e.g. the full-review escalation of design
288
+ delta reviews) are silently disabled. `commitgate sync` restores those assets from the installed package copy:
289
+
290
+ ```sh
291
+ npx commitgate sync # plan only (dry-run — see what would change)
292
+ npx commitgate sync --apply # re-sync the schema axis
293
+ npx commitgate sync --apply --persona # persona too (restore if missing; your edits are preserved)
294
+ ```
295
+
296
+ - `sync` restores the **schema axis only** (contracts, always kept current). It does not touch companion skills,
297
+ `workflow/.gitignore`, `package.json`, or `req:*`.
298
+ - The **persona (`review-persona.md`) is handled only with `--persona`**, and only as a **restore-if-missing**. A
299
+ persona you edited yourself is never overwritten (if it differs, it is preserved and only reported) — to customize
300
+ it, point `reviewPersonaPath` in `req.config.json` at a separate file.
301
+ - `req:doctor`'s **D20** WARNs when the vendored schema drifts from the installed copy (it never blocks the commit).
302
+
303
+ **③ If you are on an older (vendored) install**, follow up with `migrate` below to move to the Stage B runtime.
304
+
305
+ > In short: install `commitgate@latest` → `commitgate sync --apply` → (if needed) `commitgate migrate`.
306
+
307
+ ---
308
+
265
309
  ## Migrating from an older install (`migrate`)
266
310
 
267
311
  If `scripts/req/` is copied into your project and `req:*` points at `tsx scripts/req/*.ts`, you have an **older (vendored) install**. `init` detects this and **stops** rather than creating a silent mix, pointing you here.
@@ -472,6 +516,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
472
516
  | `npx commitgate init --dry-run` | Preview the install plan without writing files |
473
517
  | `npx commitgate init --strict` | Treat integrity warnings as install failures — stops before writing any file |
474
518
  | `npx commitgate init --no-agent-entrypoints` | Skip `.claude/`, `.cursor/`, and `CLAUDE.md` |
519
+ | `npx commitgate sync [--apply] [--persona]` | Re-sync vendored **schema-axis** assets (machine/req.config schema) to the installed package copy after an upgrade (plan-only by default). `--persona` **restores a missing persona only** (never overwrites your edits). See [Upgrading (0.x)](#upgrading-0x) |
475
520
  | `npx commitgate migrate [--apply]` | Move an older vendored install to the runtime package (plan-only by default, non-destructive) |
476
521
  | `npx commitgate uninstall` | Preview the removal plan (read-only — deletes nothing) |
477
522
  | `npm uninstall -D commitgate` | Remove the runtime |
@@ -508,6 +553,7 @@ Defaults are enough for most projects. If needed, edit `req.config.json` in the
508
553
  | `reviewModel` | `"gpt-5.6-terra"` | codex review model (pinned via `-c model=`). `null` inherits your global codex config |
509
554
  | `reviewReasoningEffort` | `"high"` | codex review reasoning effort. One of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. `null` inherits the global setting |
510
555
  | `reviewBudget` | `{ "autoBudget": 5, "hardCap": 8 }` | Re-review attempt budget for an open `(review_kind, phase_id)` review series. With the defaults, rounds 1–5 run automatically, rounds 6–8 each require a human exception record bound to that series and round, and once `hardCap` is spent the next attempt (round 9 onward) is blocked even with an exception. `hardCap ≤ 8`, `autoBudget ≤ hardCap` |
556
+ | `phaseCommit` | `{ "autoApprove": "never" }` | Per-phase auto-commit policy. `never` (default) stops for a human before every phase commit (current behavior). `low-only` auto-commits Codex-approved phases of **LOW-risk** tickets without a human stop and moves the single human confirmation to just before the feature→main merge. HIGH-risk tickets still stop at every phase under any value (`userConfirmGate` backstop). There is no `"all"` value (it would livelock on HIGH) |
511
557
 
512
558
  Empty `branchPrefix` values and paths that escape the project root are rejected.
513
559
 
package/README.md CHANGED
@@ -37,7 +37,7 @@ codex login status
37
37
  ```
38
38
 
39
39
  > **왜 두 단계인가요?** CommitGate는 실행 코드를 프로젝트에 **복사하지 않습니다**. 1단계가 런타임을 `node_modules/commitgate`에 넣고, 2단계는 프로젝트에 **거버넌스 자산**(설정·계약·스키마·persona)과 `req:* = commitgate <verb>` 스크립트만 깝니다.
40
- > 그래서 업데이트는 `npm update commitgate` 한 번이고, 런타임 제거는 `npm uninstall -D commitgate`입니다.
40
+ > 런타임 제거는 `npm uninstall -D commitgate` 한 번입니다. **업데이트는 아래 [업그레이드 (0.x)](#업그레이드-0x) 절을 따르세요** — 런타임(`node_modules`)은 `npm`으로 올리지만, 프로젝트에 깔린 vendored 자산(스키마·persona)은 `commitgate sync`로 따로 맞춰야 하고, 0.x 캐럿 범위(`^0.y`)는 minor를 자동으로 넘지 않습니다.
41
41
  > `init`은 `devDependencies.commitgate` 선언이 없으면 **중단**합니다 — `req:*`가 가리킬 런타임이 없기 때문입니다.
42
42
 
43
43
  설치는 파일을 놓기만 하고 커밋하지 않습니다. `req:new`는 **clean 워킹트리를 요구**하므로, 설치분을 먼저 커밋하세요. 설치 출력의 `다음:` 안내가 stage할 정확한 경로 목록을 알려 줍니다.
@@ -113,6 +113,12 @@ npm run req:next -- 2026-002
113
113
 
114
114
  이 루프를 끊지 말고 반복하면 설계 → Codex 리뷰 → 구현 → 재리뷰 → 커밋이 진행됩니다. 사용자는 `AWAIT_HUMAN`에서만 확인하면 됩니다.
115
115
 
116
+ > **phase 자동 커밋(opt-in).** 기본값은 매 phase 커밋 전에 `AWAIT_HUMAN`으로 멈춥니다. `req.config.json`에
117
+ > `"phaseCommit": { "autoApprove": "low-only" }`를 두면, **LOW 위험** 티켓의 Codex 승인 phase는 사람 정지 없이
118
+ > 자동 커밋되고(`req:next`가 `req:commit --run`을 RUN으로 지시), 사람 확인은 **feature→main 병합 직전 한 번**으로
119
+ > 모입니다(종단이 `DONE` 대신 `AWAIT_HUMAN`(통합)). **HIGH 티켓은 정책과 무관하게 매 phase 확인**을 유지합니다.
120
+ > 이때도 Codex 리뷰 게이트는 그대로입니다 — 제거되는 것은 LOW phase의 *사람 정지*뿐입니다.
121
+
116
122
  ### 리뷰어 페르소나는 도구가 주입합니다
117
123
 
118
124
  `req:review-codex`는 `workflow/review-persona.md`를 프롬프트 **첫 블록**으로 넣습니다. 사람이 직접 실행하든, Cursor가 실행하든, Claude가 실행하든 동일합니다 — 에이전트가 잊을 수 있는 자리에 두지 않습니다. 파일이 없거나 비어 있으면 리뷰가 fail-closed로 멈춥니다.
@@ -225,7 +231,7 @@ Matt Pocock의 MIT 공개 skills를 기준 SHA `d574778f94cf620fcc8ce741584093bc
225
231
  | `scripts/req/**` 실행 코드 | `node_modules/commitgate` — 프로젝트에 복사하지 않습니다 |
226
232
  | `tsx` · `ajv` · `cross-spawn` | `commitgate` 패키지의 runtime dependency — 대상 `package.json`에 주입하지 않습니다 |
227
233
 
228
- 프로젝트에 남는 것은 **거버넌스·감사 데이터**(설정·계약·스키마·persona·`workflow/REQ-*` 증거)뿐입니다. 실행 코드는 패키지에만 있으므로 `npm update commitgate` 번으로 갱신되고, 복사본 버전이 갈라지지 않습니다.
234
+ 프로젝트에 남는 것은 **거버넌스·감사 데이터**(설정·계약·스키마·persona·`workflow/REQ-*` 증거)뿐입니다. **실행 코드**는 패키지에만 있으므로 `npm update commitgate`로 갱신되고 복사본이 갈라질 일이 없습니다. 다만 프로젝트에 깔린 **vendored 자산**(스키마·persona)은 런타임과 별개라, minor 업그레이드 때 [업그레이드 (0.x)](#업그레이드-0x) 절을 따라 `commitgate sync`를 함께 실행해야 런타임과 갈라지지 않습니다.
229
235
 
230
236
  `req:*` 스크립트는 설치된 패키지 bin을 호출합니다 — `npm run req:new -- <slug>` → `commitgate req:new <slug>` → `node_modules/.bin/commitgate`.
231
237
 
@@ -264,6 +270,41 @@ npx commitgate --strict
264
270
 
265
271
  ---
266
272
 
273
+ ## 업그레이드 (0.x)
274
+
275
+ 런타임을 새 버전으로 올릴 때 **두 가지**를 챙겨야 합니다. `npm update`만으로는 부족합니다.
276
+
277
+ **① 캐럿 범위가 0.x minor를 막습니다.** `npm install -D commitgate`는 `^0.y.z` 범위를 씁니다. npm semver에서
278
+ `^0.7.0`은 `>=0.7.0 <0.8.0`을 뜻하므로 `npm update`/`pnpm update`는 **0.x minor를 넘지 않습니다**(0.7.x 안에 머뭅니다).
279
+ minor를 넘으려면 범위를 명시적으로 올려야 합니다:
280
+
281
+ ```sh
282
+ npm install -D commitgate@latest # 또는 특정 버전: commitgate@^0.8.0
283
+ ```
284
+
285
+ **② vendored 자산은 런타임과 별개로 갱신됩니다.** 런타임(`node_modules/commitgate`)은 위 명령이 갱신하지만,
286
+ 프로젝트 `workflow/`에 깔린 계약 자산(`machine.schema.json`·`req.config.schema.json`)은 **그대로 남습니다**.
287
+ 런타임만 올리고 자산을 두면, 새 런타임이 **옛 계약을 읽어** 새 기능(예: design delta 리뷰의 full-review
288
+ 에스컬레이션)이 조용히 비활성화될 수 있습니다. `commitgate sync`가 이 자산을 설치된 패키지 사본으로 되돌립니다:
289
+
290
+ ```sh
291
+ npx commitgate sync # 계획만 출력(dry-run — 무엇이 바뀔지 확인)
292
+ npx commitgate sync --apply # 스키마 축 재동기화
293
+ npx commitgate sync --apply --persona # 페르소나도 함께(부재면 복원, 직접 수정본은 보존)
294
+ ```
295
+
296
+ - `sync`는 **스키마 축만** 되돌립니다(계약이라 항상 최신으로). companion skills·`workflow/.gitignore`·
297
+ `package.json`·`req:*`는 건드리지 않습니다.
298
+ - **페르소나(`review-persona.md`)는 `--persona`에서만**, 그것도 **부재 복원**만 합니다. 직접 수정한 페르소나는
299
+ 덮지 않습니다(다르면 보존하고 알림만) — 커스터마이즈하려면 `req.config.json`의 `reviewPersonaPath`를 별도 파일로 지정하세요.
300
+ - `req:doctor`의 **D20**이 vendored 스키마가 설치 사본과 어긋나면 **WARN**으로 알려 줍니다(커밋은 막지 않습니다).
301
+
302
+ **③ 예전(vendored) 설치본이면** 이어서 아래 `migrate`로 Stage B 전환까지 하세요.
303
+
304
+ > 정리: `commitgate@latest` 설치 → `commitgate sync --apply` → (필요 시) `commitgate migrate`.
305
+
306
+ ---
307
+
267
308
  ## 예전 설치본에서 옮겨오기 (`migrate`)
268
309
 
269
310
  `scripts/req/`가 프로젝트에 복사돼 있고 `req:*`가 `tsx scripts/req/*.ts`를 가리킨다면 **예전(vendored) 설치본**입니다. `init`은 이 상태를 감지하면 조용히 섞이지 않도록 **중단하고** 이 명령을 안내합니다.
@@ -472,6 +513,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
472
513
  | `npx commitgate init --dry-run` | 파일을 쓰지 않고 설치 계획 확인 |
473
514
  | `npx commitgate init --strict` | 정합성 경고를 설치 실패로 처리 (gitignore된 계약 포인터, 설치 커밋을 안전하게 만들 수 없는 워킹트리 등) — 파일을 하나도 쓰기 전에 중단 |
474
515
  | `npx commitgate init --no-agent-entrypoints` | `.claude/`·`.cursor/`·`CLAUDE.md` 설치 건너뛰기 |
516
+ | `npx commitgate sync [--apply] [--persona]` | 업그레이드 후 vendored **스키마 축**(machine·req.config schema)을 설치 패키지 사본으로 재동기화 (기본: 계획만). `--persona`는 페르소나 **부재 복원만**(사용자 수정본 미훼손). 자세히는 [업그레이드 (0.x)](#업그레이드-0x) |
475
517
  | `npx commitgate migrate [--apply]` | 예전 vendored 설치본 → 런타임 패키지 전환 (기본: 계획만, 비파괴) |
476
518
  | `npx commitgate uninstall` | 제거 계획 확인 (읽기 전용 — 아무것도 지우지 않음) |
477
519
  | `npm uninstall -D commitgate` | 런타임 제거 |
@@ -508,6 +550,7 @@ yarn req:next 2026-002 # yarn
508
550
  | `reviewModel` | `"gpt-5.6-terra"` | codex 리뷰 모델(`-c model=`로 고정). `null`이면 codex 전역 설정을 상속 |
509
551
  | `reviewReasoningEffort` | `"high"` | codex 리뷰 추론강도. `none`·`minimal`·`low`·`medium`·`high`·`xhigh` 중 하나. `null`이면 전역 상속 |
510
552
  | `reviewBudget` | `{ "autoBudget": 5, "hardCap": 8 }` | 열린 `(review_kind, phase_id)` review series의 재리뷰 시도 예산. 기본값 기준 1~5회차는 자동, 6~8회차는 회차마다 그 series·회차에 바인딩된 사람 예외 기록이 있어야 진행, `hardCap` 회를 이미 소진하면 그 다음 시도(9회차부터)는 예외가 있어도 차단. `hardCap ≤ 8`·`autoBudget ≤ hardCap` |
553
+ | `phaseCommit` | `{ "autoApprove": "never" }` | phase 자동 커밋 정책. `never`(기본)면 매 phase 커밋 전에 사람 확인(현행). `low-only`면 **LOW 위험** 티켓의 Codex 승인 phase를 사람 정지 없이 자동 커밋하고 사람 확인은 feature→main 병합 직전 한 번으로 모은다. HIGH 티켓은 어느 값에서도 매 phase 확인(`userConfirmGate` 백스톱). `"all"` 같은 값은 없다(HIGH livelock 방지) |
511
554
 
512
555
  빈 `branchPrefix`나 프로젝트 밖으로 나가는 경로는 거부됩니다.
513
556
 
@@ -11,7 +11,7 @@
11
11
  * (Stage B에서 init.ts를 JS로 빌드하면 이 런처는 제거 가능.)
12
12
  *
13
13
  * verb dispatch(REQ-2026-014 Stage B, 설계 D3): 로컬 패키지 bin이 `req:*`를 dispatch한다.
14
- * - 알려진 verb(`req:new`/`req:next`/`req:review-codex`/`req:doctor`/`req:commit`/`uninstall`/`init`) → 해당 모듈(verb 토큰 소비).
14
+ * - 알려진 verb(`req:new`/`req:next`/`req:review-codex`/`req:doctor`/`req:commit`/`uninstall`/`migrate`/`sync`/`init`) → 해당 모듈(verb 토큰 소비).
15
15
  * - argv 없음 **또는 첫 인자가 `-` 옵션**(`--dry-run`·`--dir`·`--strict`·`--force`·`--no-agent-entrypoints`·`-h`) → **init에 argv 전체 전달**(하위호환).
16
16
  * - 그 외 비-옵션 미지 토큰 → fail-closed(오타를 조용히 init으로 보내지 않는다). `migrate`는 Phase 3에서 등록.
17
17
  * 각 대상 모듈은 `runCli(argv)`(예외→친절한 1줄+exit1 경계)를 export한다. import되면 대상의 `if (isMain)` 가드는 발화하지 않으므로 중복 실행 없음.
package/bin/dispatch.mjs CHANGED
@@ -20,6 +20,7 @@ export const VERB_MODULES = {
20
20
  'req:commit': '../scripts/req/req-commit.ts',
21
21
  uninstall: 'uninstall.ts',
22
22
  migrate: 'migrate.ts',
23
+ sync: 'sync.ts',
23
24
  init: 'init.ts',
24
25
  }
25
26
 
package/bin/init.ts CHANGED
@@ -396,7 +396,7 @@ export function findIgnoredArtifacts(targetRoot: string, paths: readonly string[
396
396
  * - 그 밖의 오류(EACCES 등) → fail-closed throw.
397
397
  * `workflow/.gitignore`로 preflight에서 돌리면 같은 `workflow/`를 쓰는 스키마 복사도 함께 보호된다.
398
398
  */
399
- function assertConfinedDest(targetRoot: string, destRel: string): void {
399
+ export function assertConfinedDest(targetRoot: string, destRel: string): void {
400
400
  const segs = destRel.split('/')
401
401
  let cur = targetRoot
402
402
  for (let i = 0; i < segs.length - 1; i++) {
@@ -439,7 +439,7 @@ function assertConfinedDest(targetRoot: string, destRel: string): void {
439
439
  * ⚠️ **TOCTOU는 막지 않는다.** 이 판정과 실제 쓰기 사이에 경로가 바뀌는 경쟁은 남는다
440
440
  * (Node에 `O_NOFOLLOW` 원자 API가 없다). **협조적 사용자의 우발적 symlink**를 막는 것이다.
441
441
  */
442
- function statWritableDest(targetRoot: string, destRel: string): ReturnType<typeof lstatSync> | null {
442
+ export function statWritableDest(targetRoot: string, destRel: string): ReturnType<typeof lstatSync> | null {
443
443
  assertConfinedDest(targetRoot, destRel) // 상위 컴포넌트 전부 lstat (leaf는 아래에서)
444
444
  const abs = join(targetRoot, destRel)
445
445
  let st: ReturnType<typeof lstatSync>
@@ -558,7 +558,7 @@ function gitignoreJoinsInstall(nodeModulesDirty: boolean, entries: readonly Stat
558
558
  return entries.some((e) => entryPaths(e).includes('.gitignore'))
559
559
  }
560
560
 
561
- function sha256File(abs: string): string {
561
+ export function sha256File(abs: string): string {
562
562
  return createHash('sha256').update(readFileSync(abs)).digest('hex')
563
563
  }
564
564
 
package/bin/sync.ts ADDED
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * commitgate sync — 소비 repo의 vendored **스키마 축 계약**을 설치된 패키지 사본으로 재동기화 (REQ-2026-038).
4
+ *
5
+ * 왜: 런타임은 게이트 결정 스키마(`workflow/machine.schema.json`)를 **소비 repo의 vendored 사본**에서 읽는데
6
+ * (config.ts schemaPathAbs → codex --output-schema + 응답 검증), `pnpm update`는 node_modules만 바꿔
7
+ * vendored 사본을 stale로 남긴다. `machine_schema_version`이 minor 간 불변이면(0.7.0/0.8.1 둘 다 "1.1")
8
+ * 버전으로는 감지 못 하고, stale 스키마가 신규 필드(full_review_requested)를 조용히 없애 delta 리뷰
9
+ * 에스컬레이션이 죽는다. sync가 그 vendored 계약을 shipped 사본으로 되돌린다.
10
+ *
11
+ * 하는 일(비파괴·멱등):
12
+ * - **스키마 축(`KIT_SCHEMA_RELPATHS`)**: machine.schema.json + req.config.schema.json을 `PACKAGE_ROOT/<rel>` →
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.
17
+ *
18
+ * 안 하는 일: companion skills·workflow/.gitignore·package.json·req:*·req.config.json·에이전트 진입점 미접촉.
19
+ * 캐럿 범위(`^0.x`)는 소비자 package.json에서 PM이 강제하므로 코드로 못 고친다 — 문서(업그레이드 절)가 안내.
20
+ *
21
+ * ⚠️ **confinement는 재구현하지 않는다.** 모든 쓰기가 `statWritableDest`(bin/init.ts) 단일 경로를 탄다 —
22
+ * 두 번째 구현이 REQ-2026-024 symlink-escape 결함의 원인이었다(init.ts:433).
23
+ *
24
+ * ⚠️ **`--dir`(기본 cwd)로만 root 해소** + `targetRoot===PACKAGE_ROOT` 하드 거부. `loadConfig({root})`에 root를
25
+ * 명시하므로 resolveRoot의 packageRoot fallback(config.ts:207)을 안 탄다 — migrate.ts:21-23이 경고하는 foot-gun.
26
+ *
27
+ * ⚠️ **동기 구현이어야 한다.** launcher(bin/commitgate.mjs)가 `mod.runCli(rest)`를 await 없이 호출한다 —
28
+ * async면 promise가 버려져 exit code가 소실된다(migrate.ts:18-19와 동일).
29
+ */
30
+ import { existsSync, copyFileSync, mkdirSync, realpathSync } from 'node:fs'
31
+ import { resolve, join, dirname, relative } from 'node:path'
32
+ import { pathToFileURL } from 'node:url'
33
+ 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'
35
+
36
+ export interface SyncOptions {
37
+ dir: string
38
+ /** 기본 false = plan(dry-run, 쓰기 0건). true면 스키마 축·페르소나 부재복원을 쓴다. */
39
+ apply: boolean
40
+ /** 페르소나 처리 opt-in. 없으면 페르소나는 완전 미접촉. */
41
+ persona: boolean
42
+ }
43
+
44
+ /**
45
+ * 자산 처리 상태.
46
+ * - `new`: dest 부재 → 복사(스키마/페르소나 공통, 잃을 것 없음).
47
+ * - `in-sync`: sha 동일 → skip.
48
+ * - `stale`: 스키마가 shipped와 다름 → 덮음(계약 = --force 축).
49
+ * - `preserved-differs`: 기본 경로 페르소나가 shipped와 다름 → **미접촉**(사용자 편집 보존, report-only).
50
+ * - `unmanaged-custom` / `unmanaged-null`: 페르소나 경로가 custom/null → 미접촉.
51
+ */
52
+ export type AssetStatus = 'new' | 'in-sync' | 'stale' | 'preserved-differs' | 'unmanaged-custom' | 'unmanaged-null'
53
+
54
+ export interface AssetPlan {
55
+ rel: string // 대상-상대 경로(표시용)
56
+ axis: 'schema' | 'persona'
57
+ status: AssetStatus
58
+ note?: string
59
+ }
60
+
61
+ export interface SyncPlan {
62
+ targetRoot: string
63
+ assets: AssetPlan[]
64
+ /** apply 시 실제 복사할 항목(스키마 new/stale + 페르소나 부재복원만). */
65
+ writes: { srcAbs: string; destRel: string }[]
66
+ }
67
+
68
+ /** targetRoot·PACKAGE_ROOT 동일성 판정용 정규화(Windows 8.3·case·symlink 차이 흡수 — init.assertGitWorkTree와 동일 기법). */
69
+ function canonical(p: string): string {
70
+ try {
71
+ return resolve(realpathSync.native(p))
72
+ } catch {
73
+ return resolve(p)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 재동기화 계획 수립(순수 판정 — 쓰기 없음). statWritableDest로 confinement + leaf를 판정하고 sha로 멱등 skip.
79
+ * `--persona` 없으면 페르소나는 계획에 넣지 않는다(완전 미접촉).
80
+ */
81
+ export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boolean): SyncPlan {
82
+ const assets: AssetPlan[] = []
83
+ const writes: { srcAbs: string; destRel: string }[] = []
84
+
85
+ // ── 스키마 축(무조건 재동기화 — 계약, --force 축) ──
86
+ for (const rel of KIT_SCHEMA_RELPATHS) {
87
+ const srcAbs = join(PACKAGE_ROOT, rel)
88
+ const st = statWritableDest(targetRoot, rel) // confinement + leaf(symlink escape 거부)
89
+ if (st === null) {
90
+ assets.push({ rel, axis: 'schema', status: 'new' })
91
+ writes.push({ srcAbs, destRel: rel })
92
+ } else if (sha256File(srcAbs) === sha256File(join(targetRoot, rel))) {
93
+ assets.push({ rel, axis: 'schema', status: 'in-sync' })
94
+ } else {
95
+ assets.push({ rel, axis: 'schema', status: 'stale' })
96
+ writes.push({ srcAbs, destRel: rel })
97
+ }
98
+ }
99
+
100
+ // ── 페르소나(opt-in — 파괴적 쓰기 0건) ──
101
+ if (persona) {
102
+ const personaRel = DEFAULT_REVIEW_PERSONA_RELPATH
103
+ const defaultAbs = resolve(cfg.root, personaRel)
104
+ if (cfg.reviewPersonaPathAbs === null) {
105
+ assets.push({ rel: personaRel, axis: 'persona', status: 'unmanaged-null', note: 'reviewPersonaPath:null(비활성) — 미접촉' })
106
+ } else if (cfg.reviewPersonaPathAbs !== defaultAbs) {
107
+ // custom 경로(정규화 절대경로 기준 ≠기본) — kit 관리 자산 아님. 미접촉.
108
+ const customRel = relative(cfg.root, cfg.reviewPersonaPathAbs).replace(/\\/g, '/')
109
+ assets.push({ rel: customRel, axis: 'persona', status: 'unmanaged-custom', note: 'custom reviewPersonaPath — 미접촉' })
110
+ } else {
111
+ const srcAbs = join(PACKAGE_ROOT, personaRel)
112
+ const st = statWritableDest(targetRoot, personaRel)
113
+ if (st === null) {
114
+ // 부재 복원(잃을 것 없음 — 부재면 리뷰가 이미 fail-closed로 멈춘다).
115
+ assets.push({ rel: personaRel, axis: 'persona', status: 'new' })
116
+ writes.push({ srcAbs, destRel: personaRel })
117
+ } else if (sha256File(srcAbs) === sha256File(defaultAbs)) {
118
+ assets.push({ rel: personaRel, axis: 'persona', status: 'in-sync' })
119
+ } else {
120
+ // 🔴 다름 → 절대 덮지 않는다(사용자 편집 보존). manifest 없이 stale-kit과 편집을 구별 못 함(design-r02 P1).
121
+ assets.push({
122
+ rel: personaRel,
123
+ axis: 'persona',
124
+ status: 'preserved-differs',
125
+ note: '기본 persona가 shipped와 다름 — 사용자 편집이면 유지, stale면 직접 교체(미접촉)',
126
+ })
127
+ }
128
+ }
129
+ }
130
+
131
+ return { targetRoot, assets, writes }
132
+ }
133
+
134
+ /** 계획을 사람이 읽는 줄 배열로. shell 연산자 미사용(Windows PowerShell/cmd 호환 — DEC-011-8). */
135
+ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean): string[] {
136
+ const L: string[] = []
137
+ const GLYPH: Record<AssetStatus, string> = {
138
+ new: '+',
139
+ 'in-sync': '=',
140
+ stale: '~',
141
+ 'preserved-differs': '!',
142
+ 'unmanaged-custom': '·',
143
+ 'unmanaged-null': '·',
144
+ }
145
+ L.push('')
146
+ L.push(`[commitgate sync] vendored 계약 재동기화 ${apply ? '(--apply: 파일을 씁니다)' : '계획 (dry-run — 아무것도 쓰지 않습니다)'}`)
147
+ L.push(` 대상: ${plan.targetRoot}`)
148
+ L.push('')
149
+ for (const a of plan.assets) {
150
+ const label = STATUS_LABEL[a.status]
151
+ L.push(` ${GLYPH[a.status]} [${a.axis}] ${a.rel} — ${label}${a.note ? ` (${a.note})` : ''}`)
152
+ }
153
+ if (!persona) {
154
+ L.push('')
155
+ L.push(' ℹ️ 페르소나는 미포함(--persona 로 opt-in). 스키마 축만 처리했습니다.')
156
+ }
157
+ L.push('')
158
+ 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 로 확인하고 스테이징·커밋하십시오.)`)
162
+ } else {
163
+ L.push(' 변경 없음 — 이미 동기화되어 있습니다.')
164
+ }
165
+ } else if (plan.writes.length > 0) {
166
+ L.push(` ✅ ${plan.writes.length}개 파일 갱신. 다음: git diff 로 확인 후 커밋하십시오.`)
167
+ for (const w of plan.writes) L.push(` git add -- ${w.destRel}`)
168
+ } else {
169
+ L.push(' 변경 없음 — 이미 동기화되어 있습니다(쓰기 0건).')
170
+ }
171
+ return L
172
+ }
173
+
174
+ const STATUS_LABEL: Record<AssetStatus, string> = {
175
+ new: '부재 → 복원',
176
+ 'in-sync': '최신(변경 없음)',
177
+ stale: 'stale → 갱신',
178
+ 'preserved-differs': '차이 감지 → 보존(수동 확인)',
179
+ 'unmanaged-custom': 'custom 경로(unmanaged)',
180
+ 'unmanaged-null': '비활성(unmanaged)',
181
+ }
182
+
183
+ /**
184
+ * 실행. 기본 plan(dry-run, 쓰기 0건), `--apply`에서만 쓴다.
185
+ *
186
+ * 🔴 packageRoot 가드: `targetRoot===PACKAGE_ROOT`면 어떤 쓰기 전에도 거부(fail-closed). CommitGate 패키지 자신을
187
+ * 재작성하는 사고를 막는다. `loadConfig({root})` 명시로 resolveRoot fallback도 원천 차단(이중 방어).
188
+ */
189
+ export function runSync(opts: SyncOptions): SyncPlan {
190
+ const targetRoot = resolve(opts.dir)
191
+ if (!existsSync(targetRoot)) throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
192
+ assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
193
+ if (canonical(targetRoot) === canonical(PACKAGE_ROOT))
194
+ throw new Error('sync 대상이 CommitGate 패키지 자신입니다 — 소비 repo(commitgate를 devDependency로 설치한 곳)에서 실행하세요.')
195
+
196
+ const cfg = loadConfig({ root: targetRoot }) // root 명시 → resolveRoot의 packageRoot fallback 안 탐
197
+ const plan = planSync(targetRoot, cfg, opts.persona)
198
+
199
+ if (opts.apply) {
200
+ for (const w of plan.writes) {
201
+ // 쓰기 직전 confinement 재검증(planSync와 apply 사이 TOCTOU 최소화 — 단일 경로 재사용).
202
+ statWritableDest(targetRoot, w.destRel)
203
+ const destAbs = join(targetRoot, w.destRel)
204
+ mkdirSync(dirname(destAbs), { recursive: true })
205
+ copyFileSync(w.srcAbs, destAbs)
206
+ }
207
+ }
208
+
209
+ for (const line of renderPlan(plan, opts.apply, opts.persona)) console.log(line)
210
+ return plan
211
+ }
212
+
213
+ /** CLI 파싱(fail-closed). `--flag=value` 미지원, 미지 토큰은 throw(init/migrate 관례). */
214
+ export function parseArgs(argv: string[]): SyncOptions {
215
+ let dir = process.cwd()
216
+ let apply = false
217
+ let persona = false
218
+ for (let i = 0; i < argv.length; i++) {
219
+ const a = argv[i]
220
+ if (a === '--dir') {
221
+ const v = argv[i + 1]
222
+ if (v === undefined) throw new Error('--dir 값 누락')
223
+ dir = v
224
+ i++
225
+ } else if (a === '--apply') {
226
+ apply = true
227
+ } else if (a === '--persona') {
228
+ persona = true
229
+ } else if (a === '--dry-run') {
230
+ apply = false // 기본값이지만 명시 허용
231
+ } else if (a === '-h' || a === '--help') {
232
+ printHelp()
233
+ process.exit(0)
234
+ } else {
235
+ throw new Error(`알 수 없는 인자: ${a}`)
236
+ }
237
+ }
238
+ return { dir: resolve(dir), apply, persona }
239
+ }
240
+
241
+ function printHelp(): void {
242
+ console.log(`commitgate sync — vendored 스키마 축 계약을 설치된 패키지 사본으로 재동기화
243
+
244
+ 사용법:
245
+ npx commitgate sync [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
246
+ npx commitgate sync --apply [--dir <대상repo>] 스키마 축 재동기화
247
+ npx commitgate sync --apply --persona 스키마 + 페르소나(부재 복원만) 재동기화
248
+
249
+ 하는 일:
250
+ workflow/machine.schema.json · workflow/req.config.schema.json 을 설치된 패키지 사본으로 되돌립니다.
251
+ --persona: 페르소나가 부재면 복원합니다. 내용이 다르면(사용자 편집 가능성) 덮지 않고 보존만 합니다.
252
+
253
+ 하지 않는 일:
254
+ companion skills · workflow/.gitignore · package.json · req:* · req.config.json 은 건드리지 않습니다.
255
+ 캐럿 범위(^0.x)는 자동으로 못 넘깁니다 — README '업그레이드(0.x)' 절을 참고해 범위를 먼저 올리세요.
256
+ `)
257
+ }
258
+
259
+ export function runCli(argv: string[]): void {
260
+ try {
261
+ runSync(parseArgs(argv))
262
+ } catch (err) {
263
+ console.error(`commitgate sync: ${err instanceof Error ? err.message : String(err)}`)
264
+ process.exitCode = 1
265
+ }
266
+ }
267
+
268
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
269
+ if (isMain) runCli(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitgate",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@
9
9
  "reviewModel": "gpt-5.6-terra",
10
10
  "reviewReasoningEffort": "high",
11
11
  "reviewBudget": { "autoBudget": 5, "hardCap": 8 },
12
+ "phaseCommit": { "autoApprove": "never" },
12
13
  "designDocs": {
13
14
  "requirement": "00-requirement.md",
14
15
  "design": "01-design.md",
@@ -33,6 +33,18 @@ export interface ReviewBudget {
33
33
  hardCap: number
34
34
  }
35
35
 
36
+ /**
37
+ * phase 자동 커밋 정책(REQ-2026-037). **opt-in** — 코어 기본은 `never`(현행: 매 phase `AWAIT_HUMAN` 정지).
38
+ * - `never` : Codex 승인 phase마다 사람 확인(`req:commit --run`). 배포 안전도구의 무회귀 기본값.
39
+ * - `low-only`: `risk_level==='LOW'`(정확 일치)인 phase를 사람 정지 없이 자동 커밋. HIGH는 정책과 무관하게
40
+ * 정지(`req-commit`의 Gate B가 이중 백스톱). 정책 `"all"`은 **없다** — HIGH는 매 phase 신선한
41
+ * `user_commit_confirmed`를 요구하므로 자동 커밋이 livelock/위조를 부른다(REQ-2026-019 폐기 사유).
42
+ */
43
+ export type PhaseCommitPolicy = 'never' | 'low-only'
44
+ export interface PhaseCommit {
45
+ autoApprove: PhaseCommitPolicy
46
+ }
47
+
36
48
  /** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
37
49
  export interface RawConfig {
38
50
  ticketRoot?: string
@@ -50,6 +62,8 @@ export interface RawConfig {
50
62
  reviewReasoningEffort?: ReviewReasoningEffort | null
51
63
  /** REQ-2026-028 A-2a: review 예산. 미지정 = DEFAULTS(5/8). hardCap≤8·autoBudget≤hardCap은 loadConfig 검증. */
52
64
  reviewBudget?: ReviewBudget
65
+ /** REQ-2026-037: phase 자동 커밋 정책. 미지정 = DEFAULTS(never = 현행 매 phase 정지). */
66
+ phaseCommit?: PhaseCommit
53
67
  }
54
68
 
55
69
  /** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
@@ -66,6 +80,7 @@ export interface ResolvedConfig {
66
80
  reviewModel: string | null
67
81
  reviewReasoningEffort: ReviewReasoningEffort | null
68
82
  reviewBudget: ReviewBudget
83
+ phaseCommit: PhaseCommit
69
84
  // 파생(절대경로)
70
85
  workflowDirAbs: string
71
86
  schemaPathAbs: string
@@ -115,6 +130,8 @@ export const DEFAULTS = {
115
130
  reviewReasoningEffort: 'high' as ReviewReasoningEffort | null,
116
131
  // REQ-2026-028 A-2a: review 예산. autoBudget=자동 허용 회차, hardCap=절대 상한(9번째 차단 → 8).
117
132
  reviewBudget: { autoBudget: 5, hardCap: 8 } as ReviewBudget,
133
+ // REQ-2026-037: phase 자동 커밋은 opt-in. 코어 기본 never = 현행(매 phase 정지) — 업그레이드로 완화되지 않는다.
134
+ phaseCommit: { autoApprove: 'never' } as PhaseCommit,
118
135
  }
119
136
 
120
137
  const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
@@ -146,6 +163,15 @@ export const CONFIG_SCHEMA = {
146
163
  hardCap: { type: 'integer', minimum: 1, maximum: 8 },
147
164
  },
148
165
  },
166
+ // REQ-2026-037: phase 자동 커밋 정책. autoApprove enum이 정책을 고정 — `"all"`은 없다(HIGH livelock 방지).
167
+ phaseCommit: {
168
+ type: 'object',
169
+ additionalProperties: false,
170
+ required: ['autoApprove'],
171
+ properties: {
172
+ autoApprove: { type: 'string', enum: ['never', 'low-only'] },
173
+ },
174
+ },
149
175
  designDocs: {
150
176
  type: 'object',
151
177
  additionalProperties: false,
@@ -231,6 +257,8 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
231
257
  reviewReasoningEffort:
232
258
  raw.reviewReasoningEffort !== undefined ? raw.reviewReasoningEffort : DEFAULTS.reviewReasoningEffort,
233
259
  reviewBudget: raw.reviewBudget ?? DEFAULTS.reviewBudget,
260
+ // REQ-2026-037: 미지정 → DEFAULTS(never). `?? `로 충분(phaseCommit은 nullable 아님 — null 탈출구 없음).
261
+ phaseCommit: raw.phaseCommit ?? DEFAULTS.phaseCommit,
234
262
  }
235
263
 
236
264
  // REQ-2026-028 R7: 교차검증(스키마가 표현 못 함). AJV가 이미 hardCap∈[1,8]·autoBudget≥1을 잡았고,
@@ -24,7 +24,7 @@ import {
24
24
  type Verdict,
25
25
  type ApprovalEvidence,
26
26
  } from './review-codex'
27
- import { loadConfig, packageRoot, stripBom, type ResolvedConfig } from './lib/config'
27
+ import { loadConfig, packageRoot, stripBom, DEFAULTS, type ResolvedConfig } from './lib/config'
28
28
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
29
29
 
30
30
  // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — config 부재 시 현재 동작 보존).
@@ -81,6 +81,18 @@ export interface DoctorInputs {
81
81
  * 리터럴이 즉시 tsc 오류가 난다(기존 optional 필드 관례와 동일).
82
82
  */
83
83
  reqScripts?: Record<string, string> | null
84
+ // D20(REQ-2026-038): vendored 자산 skew content-hash 검사. main()이 계산해 채운다(runChecks는 순수).
85
+ // - packagedSchemaSha : packageRoot()/workflow/machine.schema.json 의 sha256 (조회 불가 시 null)
86
+ // - vendoredSchemaSha : cfg.schemaPathAbs(소비 repo 사본)의 sha256 (조회 불가 시 null)
87
+ // - packageRootDiffers: packageRoot() !== cfg.root (dogfood/dev repo면 false → OK, D19 자기보호와 동일 취지)
88
+ // - schemaPathIsDefault: cfg.schemaPathAbs === resolve(cfg.root, DEFAULTS.schemaPath) (**정규화 절대경로** 비교 — 동치 상대경로 포함)
89
+ // - installedVersion : packageRoot()/package.json 의 version (WARN 메시지용)
90
+ // 미지정(undefined) = 계산 안 함(legacy/2-arg 호출) → OK '점검 불요'. optional이어야 테스트의 base 리터럴이 안 깨진다(reqScripts와 동일).
91
+ packagedSchemaSha?: string | null
92
+ vendoredSchemaSha?: string | null
93
+ packageRootDiffers?: boolean
94
+ schemaPathIsDefault?: boolean
95
+ installedVersion?: string | null
84
96
  }
85
97
 
86
98
  /**
@@ -422,6 +434,32 @@ export function runChecks(inp: DoctorInputs): Check[] {
422
434
  else c.push({ id: 'D19', level: 'OK', msg: `설치 모드: ${INSTALL_MODE_LABEL[mode]}(req:* 스크립트 형태 기준)` })
423
435
  }
424
436
 
437
+ // D20(REQ-2026-038): vendored machine.schema.json 자산 skew(content-hash) 진단.
438
+ //
439
+ // 🔴 **level 상한은 WARN — 절대 FAIL이 아니다**(D19 :406-411과 동일 근거). `req:commit`이 이 doctor를 exit≠0에
440
+ // throw하는 하드 게이트로 spawn하므로, FAIL이면 skew난 소비자의 모든 커밋이 `commitgate sync` 전까지 벽돌이 된다.
441
+ // 확인된 피해는 데이터 손실이 아니라 **조용한 기능 상실**(stale 스키마가 full_review_requested를 제거해 delta 리뷰
442
+ // full-review 에스컬레이션이 죽음) → WARN이 정확한 강도.
443
+ // 🔴 **content-hash 비교**(버전 비교 아님): machine_schema_version이 minor 간 불변일 수 있어(0.7.0/0.8.1 둘 다 "1.1")
444
+ // 버전으로는 이 skew를 못 잡는다. sha256(shipped) vs sha256(vendored)만 잡는다.
445
+ // 결정표(D19의 undefined→OK 선례): dev repo/dogfood·custom schemaPath·조회 불가·동일 → OK. 상이 → WARN.
446
+ if (inp.packageRootDiffers === false) {
447
+ c.push({ id: 'D20', level: 'OK', msg: '자산 skew 점검 불요(dev repo/dogfood — packageRoot === config root)' })
448
+ } else if (inp.schemaPathIsDefault === false) {
449
+ c.push({ id: 'D20', level: 'OK', msg: 'custom schemaPath(kit 관리 자산 아님 — unmanaged, 점검 불요)' })
450
+ } else if (!inp.packagedSchemaSha || !inp.vendoredSchemaSha) {
451
+ c.push({ id: 'D20', level: 'OK', msg: '자산 skew 점검 불요(shipped/vendored 스키마 조회 불가 — Stage A/미설치/2-arg)' })
452
+ } else if (inp.packagedSchemaSha === inp.vendoredSchemaSha) {
453
+ c.push({ id: 'D20', level: 'OK', msg: 'vendored machine.schema.json 동기화됨(shipped와 동일)' })
454
+ } else {
455
+ const ver = inp.installedVersion ? `commitgate ${inp.installedVersion}` : '설치된 commitgate'
456
+ c.push({
457
+ id: 'D20',
458
+ level: 'WARN',
459
+ msg: `vendored workflow/machine.schema.json 이 ${ver} 사본과 불일치(stale) — \`commitgate sync --apply\` 로 재동기화하세요. stale 스키마는 신규 필드(full_review_requested)를 조용히 제거해 design delta 리뷰의 full-review 에스컬레이션을 비활성화합니다(content-hash 감지).`,
460
+ })
461
+ }
462
+
425
463
  return c
426
464
  }
427
465
 
@@ -507,6 +545,32 @@ function branchExistsLocal(branch: string): boolean {
507
545
  }
508
546
  }
509
547
 
548
+ /**
549
+ * 파일 sha256(hex). 부재·오류 시 null — D20 fail-safe(조회 불가는 OK로 처리, 게이트를 막지 않는다).
550
+ *
551
+ * ⚠️ `createHash`는 이 파일 상단(`import { createHash } from 'node:crypto'`, :13)에 **이미** import돼 있다
552
+ * — D16 live-sha·evidence archive sha가 공유하는 기존 import다. D20용으로 추가 import는 필요 없다(중복이면 오류).
553
+ * `export`인 이유: 테스트가 **실제 createHash 경로**를 직접 구동해(합성 sha 문자열이 아니라) req-doctor의 sha 계산이
554
+ * 실제로 동작함을 증명하기 위함이다(REQ-2026-038 phase-2 리뷰 대응).
555
+ */
556
+ export function safeSha256(abs: string): string | null {
557
+ try {
558
+ return createHash('sha256').update(readFileSync(abs)).digest('hex')
559
+ } catch {
560
+ return null
561
+ }
562
+ }
563
+
564
+ /** package.json의 version 문자열. 부재·파손 시 null(D20 WARN 메시지용 — 없어도 무해). */
565
+ function safeReadVersion(pkgAbs: string): string | null {
566
+ try {
567
+ const raw = JSON.parse(stripBom(readFileSync(pkgAbs, 'utf8'))) as { version?: unknown }
568
+ return typeof raw.version === 'string' ? raw.version : null
569
+ } catch {
570
+ return null
571
+ }
572
+ }
573
+
510
574
  export function main(argv: string[] = process.argv.slice(2)): void {
511
575
  const opts = parseArgs(argv)
512
576
  const cfg = loadConfig({ root: opts.root })
@@ -606,6 +670,12 @@ export function main(argv: string[] = process.argv.slice(2)): void {
606
670
  finalize,
607
671
  finalizeSourceTree,
608
672
  reqScripts: readReqScripts(cfg.root),
673
+ // D20(REQ-2026-038): 자산 skew content-hash 입력. shipped=packageRoot 사본, vendored=cfg.schemaPathAbs(소비 repo 사본).
674
+ packagedSchemaSha: safeSha256(join(packageRoot(), 'workflow', 'machine.schema.json')),
675
+ vendoredSchemaSha: safeSha256(cfg.schemaPathAbs),
676
+ packageRootDiffers: packageRoot() !== cfg.root,
677
+ schemaPathIsDefault: cfg.schemaPathAbs === resolve(cfg.root, DEFAULTS.schemaPath), // 정규화 절대경로 비교(동치 상대경로 포함)
678
+ installedVersion: safeReadVersion(join(packageRoot(), 'package.json')),
609
679
  }
610
680
 
611
681
  const checks = runChecks(inp)
@@ -36,7 +36,7 @@ import {
36
36
  type ReviewKind,
37
37
  type LastReviewMarker,
38
38
  } from './review-codex'
39
- import type { ReviewBudget } from './lib/config'
39
+ import type { ReviewBudget, PhaseCommitPolicy } from './lib/config'
40
40
 
41
41
  // ─────────────────────────────────────────────── 읽기 전용 git 경계 (D6-1) ──
42
42
 
@@ -136,6 +136,11 @@ export interface NextInput {
136
136
  currentIndexHash: string | null
137
137
  /** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
138
138
  reviewBudget: ReviewBudget
139
+ /**
140
+ * REQ-2026-037: phase 자동 커밋 정책. main이 `cfg.phaseCommit.autoApprove`로 채운다(항상 존재 — DEFAULTS=never).
141
+ * 필수 필드다(선택 아님): 해소는 config 계층에서 끝나므로 resolveNext는 내부 기본값을 두지 않는다.
142
+ */
143
+ phaseCommitAutoApprove: PhaseCommitPolicy
139
144
  }
140
145
 
141
146
  /** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
@@ -325,6 +330,23 @@ function commitCmd(pm: PackageManager, target: NextTarget): string {
325
330
  return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run']).join(' ')
326
331
  }
327
332
 
333
+ /**
334
+ * REQ-2026-037: 자동 커밋 RUN 명령. `commitCmd`와 달리 `-m "<메시지>"` 자리표시자를 싣는다 — `req:commit`은
335
+ * 메시지 없이는 fail-closed로 죽기 때문(read-only인 req:next는 메시지를 합성할 수 없다). 에이전트가 이
336
+ * 자리표시자를 실제 conventional 메시지로 바꿔 실행한다(AGENT 단계에서 `git add` 대상을 고르는 것과 동형).
337
+ */
338
+ function autoCommitCmd(pm: PackageManager, target: NextTarget): string {
339
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run', '-m', '"<이 phase의 conventional 커밋 메시지>"']).join(' ')
340
+ }
341
+
342
+ /**
343
+ * REQ-2026-037: 부분 커밋(source 커밋 후 consume 전) 복구 명령. `req:commit --finalize --run`은 source를
344
+ * 재커밋하지 않고 evidence/consume만 복구한다 — 복구 가드가 안내하는 정확한 명령(detail·command·approvalSentence 일관).
345
+ */
346
+ function finalizeCmd(pm: PackageManager, target: NextTarget): string {
347
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--finalize', '--run']).join(' ')
348
+ }
349
+
328
350
  /**
329
351
  * 대상(REQ id 또는 `--ticket`) 미지정 에러 문구(DEC-011-1). **config 로드 이후**라 pm별로 파생한다.
330
352
  * 리터럴을 박으면 다른 pm 프로젝트의 사용자가 그대로 따라 할 수 없는 명령을 안내받는다.
@@ -477,7 +499,29 @@ export function resolveNext(input: NextInput): NextAction {
477
499
  }
478
500
 
479
501
  // 1. 살아 있는 승인이 가장 쉽게 상한다 — 다른 어떤 행동도 D9(staged tree == approved tree)를 깨뜨린다.
480
- if (state.commit_allowed === true)
502
+ if (state.commit_allowed === true) {
503
+ // REQ-2026-037: opt-in 자동 커밋. **fail-closed** — `risk_level==='LOW'` 정확 일치 AND 정책 low-only AND
504
+ // staged 존재일 때만 RUN(자동 커밋). "HIGH가 아님"이 "자동 안전"을 의미하지 않는다: 누락·`'Low'` 오타·
505
+ // 손상·HIGH는 전부 else(AWAIT_HUMAN)로 떨어지고, HIGH는 req-commit의 Gate B가 이중 백스톱.
506
+ const autoCommit =
507
+ input.phaseCommitAutoApprove === 'low-only' && state.risk_level === 'LOW' && input.hasStagedChanges
508
+ if (autoCommit)
509
+ return {
510
+ kind: 'RUN',
511
+ detail: 'phase 승인이 살아 있다(LOW · 자동 커밋). 이 phase의 conventional 커밋 메시지를 작성해 실행하라.',
512
+ command: autoCommitCmd(pm, target),
513
+ }
514
+ // 복구 가드(R4): 승인이 살아 있는데 staged가 비었으면 부분 커밋(source 커밋 후 consume 전)일 수 있다.
515
+ // 정상 커밋을 지시하면 req:commit이 `staged 변경 없음`으로 죽어 자동 루프가 스핀한다 → --finalize로 복구.
516
+ // detail·command·controlPoint·approvalSentence를 모두 finalize로 맞춘다(phase-2 리뷰 observation).
517
+ if (!input.hasStagedChanges)
518
+ return {
519
+ kind: 'AWAIT_HUMAN',
520
+ detail: 'phase 승인이 살아 있으나 staged가 비었다 — 부분 커밋일 수 있다. `req:commit --finalize --run`으로 복구가 필요하다.',
521
+ command: finalizeCmd(pm, target),
522
+ controlPoint: 'req:commit --finalize --run 직전',
523
+ approvalSentence: 'req:commit --finalize --run 승인',
524
+ }
481
525
  return {
482
526
  kind: 'AWAIT_HUMAN',
483
527
  detail: 'phase 승인이 살아 있다. 커밋 전 사람 확인이 필요하다.',
@@ -485,6 +529,7 @@ export function resolveNext(input: NextInput): NextAction {
485
529
  controlPoint: 'req:commit --run 직전',
486
530
  approvalSentence: 'req:commit --run 승인',
487
531
  }
532
+ }
488
533
 
489
534
  // 1.5 legacy ticket(REQ-2026-027 D1): 모델 버전 부재 = legacy. 살아 있는 승인(1번)보다는 뒤 —
490
535
  // 그건 소비만 하면 되고 새 외부 호출이 아니다. design/phase RUN 후보(2·3번)보다는 **앞** — 그 후보를
@@ -555,12 +600,26 @@ export function resolveNext(input: NextInput): NextAction {
555
600
  })
556
601
  }
557
602
 
558
- if (input.worktreeReviewClean && !input.hasStagedChanges)
603
+ if (input.worktreeReviewClean && !input.hasStagedChanges) {
604
+ // REQ-2026-037 R5: 자동 커밋(low-only)에선 매 phase 정지가 없으므로, 병합 전 **단일** 사람 확인을
605
+ // 종단에서 실체화한다 — DONE(exit 11)이 아니라 AWAIT_HUMAN(exit 10)으로 루프를 확실히 멈춘다.
606
+ // 승인 문장은 새로 만들지 않고 계약 통제점표(I1/I2/B1)의 정본을 가리킨다(design-r01 observation).
607
+ if (input.phaseCommitAutoApprove === 'low-only')
608
+ return {
609
+ kind: 'AWAIT_HUMAN',
610
+ detail:
611
+ '모든 phase가 자동 커밋됐다. feature→main 통합은 사람 승인이 필요하다 — 경로(PR 또는 direct push)와 승인 문장은 AGENTS.md 통제점표(I1/I2/B1)를 따른다.',
612
+ controlPoint: '통합(feature→main)',
613
+ approvalSentence:
614
+ '통합 경로를 택하고 그 통제점의 정본 승인 문장을 받는다 — [I1] feature branch push + PR 생성 승인 → [I2] required checks green 확인 후 PR merge 승인, 또는 [B1] branch protection bypass를 사용한 direct push 승인',
615
+ }
616
+ // never(기본): 현행 그대로 DONE — 기존 사용자 무회귀.
559
617
  return {
560
618
  kind: 'DONE',
561
619
  detail:
562
620
  '모든 phase가 승인·커밋됐다. 다음은 통합 통제점 — `[I1]` PR 생성 또는 `[B1]` protected branch direct push. 경로 선택과 승인은 사람이 한다.',
563
621
  }
622
+ }
564
623
 
565
624
  return {
566
625
  kind: 'BLOCKED',
@@ -692,6 +751,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
692
751
  worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
693
752
  currentIndexHash: captureIndexHash(roGit),
694
753
  reviewBudget: cfg.reviewBudget,
754
+ phaseCommitAutoApprove: cfg.phaseCommit.autoApprove,
695
755
  })
696
756
 
697
757
  if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))
@@ -80,6 +80,22 @@
80
80
  }
81
81
  }
82
82
  },
83
+ "phaseCommit": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "required": [
87
+ "autoApprove"
88
+ ],
89
+ "properties": {
90
+ "autoApprove": {
91
+ "type": "string",
92
+ "enum": [
93
+ "never",
94
+ "low-only"
95
+ ]
96
+ }
97
+ }
98
+ },
83
99
  "designDocs": {
84
100
  "type": "object",
85
101
  "additionalProperties": false,