commitgate 0.3.1 → 0.4.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.
@@ -17,8 +17,8 @@
17
17
  * pnpm req:review-codex --ticket <dir> # 임의 티켓 디렉터리
18
18
  * 옵션: --handoff <path> (미지정 시 req.config.json의 handoffPath. 둘 다 없으면 handoff 블록 생략 — 코어 기본은 비활성)
19
19
  */
20
- import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'
21
- import { resolve, join, relative } from 'node:path'
20
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs'
21
+ import { resolve, join, relative, sep } from 'node:path'
22
22
  import { pathToFileURL } from 'node:url'
23
23
  import { createHash } from 'node:crypto'
24
24
  import Ajv from 'ajv'
@@ -67,6 +67,8 @@ export interface ReviewContext {
67
67
  }
68
68
 
69
69
  export interface ReviewPromptInput {
70
+ /** 리뷰어 역할 정의(REQ-2026-010 D1). 첫 블록. null/공백이면 생략. 본문 문자열이지 경로가 아니다. */
71
+ persona?: string | null
70
72
  handoff?: string | null
71
73
  reviewContext?: ReviewContext | null
72
74
  reviewBaseSha: string
@@ -78,16 +80,20 @@ export interface ReviewPromptInput {
78
80
 
79
81
  /**
80
82
  * 순수 함수: 리뷰 프롬프트 조립 (§9.5).
81
- * 순서 = [handoff?] → [Review Context?] → REVIEW_BASE_SHA → REVIEW_KIND → codex-request 본문 → 권위 아티팩트.
83
+ * 순서 = [persona?] → [handoff?] → [Review Context?] → REVIEW_BASE_SHA → REVIEW_KIND → codex-request 본문 → 권위 아티팩트.
82
84
  * 권위 아티팩트: kind=phase → staged diff(현행), kind=design → 설계 문서 00/01/02 본문(DEC-WF-027 결정#3).
83
- * handoff·reviewContext는 선택. 빈 request는 fail-closed로 거부. kind 기본값 phase(하위호환).
85
+ * persona·handoff·reviewContext는 선택. 빈 request는 fail-closed로 거부. kind 기본값 phase(하위호환).
86
+ *
87
+ * persona가 맨 앞인 이유(REQ-2026-010 D1): 리뷰어의 **역할 정의**는 컨텍스트·판정 대상보다 먼저 와야 한다.
88
+ * ⚠️ 이 함수는 파일을 읽지 않는다 — persona는 이미 읽힌 **본문**이다. 읽기·부재 판정은 `loadReviewPersona`가 한다.
84
89
  */
85
90
  export function assembleReviewPrompt(input: ReviewPromptInput): string {
86
- const { handoff, reviewContext, reviewBaseSha, requestBody, stagedDiff, designDocs } = input
91
+ const { persona, handoff, reviewContext, reviewBaseSha, requestBody, stagedDiff, designDocs } = input
87
92
  const kind: ReviewKind = input.reviewKind ?? 'phase'
88
93
  if (!reviewBaseSha) throw new Error('reviewBaseSha 필요')
89
94
  if (!requestBody || !requestBody.trim()) throw new Error('codex-request.md 본문이 비어 있음')
90
95
  const blocks: string[] = []
96
+ if (persona && persona.trim()) blocks.push(persona.trim())
91
97
  if (handoff && handoff.trim()) blocks.push(handoff.trim())
92
98
  if (reviewContext) {
93
99
  blocks.push(
@@ -123,6 +129,48 @@ export function assembleReviewPrompt(input: ReviewPromptInput): string {
123
129
  return blocks.join('\n')
124
130
  }
125
131
 
132
+ /**
133
+ * persona 문서 로드 — **fail-closed** (REQ-2026-010 D3).
134
+ *
135
+ * `handoff`의 `existsSync` silent-skip 패턴을 의도적으로 **따르지 않는다**:
136
+ * - handoff는 있으면 좋은 **읽기 전용 참조**라, 없으면 조용히 생략해도 리뷰가 성립한다.
137
+ * - persona는 **리뷰 품질 계약**이다. 조용히 빠진 채 exit 0으로 승인이 나오면,
138
+ * "약한 리뷰가 통과했다"는 신호가 어디에도 남지 않는다 — 정확히 이 티켓이 없애려는 실패 양식.
139
+ *
140
+ * 비활성 경로는 **하나뿐**이다: `req.config.json`에 `reviewPersonaPath: null`을 명시한다(암묵 < 명시).
141
+ *
142
+ * 거부하는 것 — 셋 다 "persona 없이 리뷰가 exit 0으로 통과"하거나 계약을 우회하는 경로다.
143
+ *
144
+ * 1. **부재**.
145
+ * 2. **빈 내용**(0바이트·공백 only) — phase-1b R1 P2. `assembleReviewPrompt`가 `persona.trim()`으로 블록을
146
+ * 생략하므로, 내용을 안 보면 fail-closed 계약이 **파일 하나 비우는 것으로 무너진다.**
147
+ * 3. **realpath가 root 밖이거나 일반 파일이 아닌 경우** — phase-1b R2 P2. `loadConfig`의 confinement는
148
+ * config의 **문자열 경로**만 검사하는데 `readFileSync`는 **symlink를 따라간다.** `workflow/review-persona.md`를
149
+ * repo 밖 파일로 향하는 링크로 바꾸면 그 내용이 프롬프트 첫 블록으로 Codex에 전송된다(D2 계약 우회 + 유출).
150
+ * 그래서 읽기 직전에 **realpath 기준으로** root 하위 regular file인지 다시 확인한다.
151
+ *
152
+ * `rootAbs`도 realpath로 정규화한다 — 임시 디렉터리(예: macOS `/tmp` → `/private/tmp`)처럼 root 자체가
153
+ * symlink 경유일 때 문자열 비교가 거짓 음성을 내기 때문이다.
154
+ */
155
+ export function loadReviewPersona(pathAbs: string | null, rootAbs: string): string | null {
156
+ if (pathAbs === null) return null
157
+ const recovery = ` → \`npx commitgate --force\`로 복원하거나, 의도한 비활성이면 req.config.json에 "reviewPersonaPath": null 을 명시하세요.`
158
+ if (!existsSync(pathAbs)) throw new Error(`리뷰어 페르소나 문서 없음: ${pathAbs}\n${recovery}`)
159
+
160
+ const rootReal = resolve(realpathSync(rootAbs))
161
+ const targetReal = resolve(realpathSync(pathAbs)) // symlink 해소
162
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep))
163
+ throw new Error(
164
+ `리뷰어 페르소나 문서가 repo 밖을 가리킵니다(symlink?): ${pathAbs} → ${targetReal}\n${recovery}`,
165
+ )
166
+ if (!statSync(targetReal).isFile())
167
+ throw new Error(`리뷰어 페르소나 문서가 일반 파일이 아닙니다: ${pathAbs}\n${recovery}`)
168
+
169
+ const body = readFileSync(targetReal, 'utf8')
170
+ if (!body.trim()) throw new Error(`리뷰어 페르소나 문서가 비어 있음: ${pathAbs}\n${recovery}`)
171
+ return body
172
+ }
173
+
126
174
  /**
127
175
  * git 바인딩 캡처 (§8.4): diff '텍스트'가 아니라 staged **tree OID**(git write-tree)를 바인딩.
128
176
  * gitFn 주입 가능(테스트용).
@@ -133,6 +181,24 @@ export function captureGitBinding(gitFn: GitFn = git): { reviewBaseSha: string;
133
181
  return { reviewBaseSha, reviewTree }
134
182
  }
135
183
 
184
+ /**
185
+ * 인덱스 전체의 **읽기 전용** 신원 해시 (REQ-2026-010 D6-2).
186
+ *
187
+ * `captureGitBinding`의 tree OID와 값은 다르지만 **동치 관계**다: 인덱스 내용(mode·blob sha·stage·path)이
188
+ * 같으면 같고 다르면 다르다. 존재 이유는 `req:next`가 `git write-tree`를 **부를 수 없기 때문**이다 —
189
+ * 그 명령은 object DB에 tree object를 쓴다(D6-1의 무쓰기 계약 위반).
190
+ *
191
+ * ⚠️ 승인 바인딩이 아니다. `approved_diff_hash`는 여전히 tree OID다. 이 해시는 `last_review.compare_hash`
192
+ * 전용이고, 어떤 게이트(D6/D9/doctor)도 읽지 않는다. 이 경계가 흐려지면 D9가 다른 해시에 바인딩된다.
193
+ */
194
+ export function captureIndexHash(gitFn: GitFn = git): string {
195
+ const lines = gitFn(['ls-files', '-s'])
196
+ .split('\n')
197
+ .map((l) => l.trim())
198
+ .filter(Boolean)
199
+ return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
200
+ }
201
+
136
202
  /** 티켓 설계 문서 3종의 repo-relative 경로. shorthand 금지 — 각 경로를 티켓 디렉터리로 정규화. 파일명은 config(designDocs) 주입. */
137
203
  export function designDocPaths(ticketRelDir: string, designDocs: DesignDocs): [string, string, string] {
138
204
  const dir = ticketRelDir.replace(/\\/g, '/').replace(/\/+$/, '')
@@ -327,6 +393,65 @@ export interface BlockedReviewMarker extends BlockedReviewTarget {
327
393
  blocked_at: string
328
394
  }
329
395
 
396
+ /**
397
+ * 직전 리뷰의 **자문(advisory) 마커** — REQ-2026-010 D6-2. `req:next`의 G2(바인딩 신선도) 전용.
398
+ *
399
+ * ⚠️ **어떤 게이트도 이 필드를 읽지 않는다.** 승인 바인딩은 `approved_diff_hash`(tree OID) /
400
+ * `design_approved_hash`이고, `req:doctor`의 D-체크도 여기를 보지 않는다. `req:next`가
401
+ * "이 바인딩은 직전 리뷰가 이미 보고 승인하지 않았다"를 알아 무한 재리뷰 루프를 끊는 데만 쓴다.
402
+ *
403
+ * `compare_hash`는 **읽기 전용 명령으로 재계산 가능한** 값이어야 한다(`req:next`는 `write-tree` 금지):
404
+ * - design → `captureDesignBinding`의 designHash (`git ls-files -s -- <00,01,02>`)
405
+ * - phase → `captureIndexHash` (`git ls-files -s` 전체)
406
+ *
407
+ * `errors`는 `outcome === 'invalid'`일 때만 채운다 — `req:next`는 검증기를 다시 돌리지 않으므로
408
+ * 진단 본문을 리뷰 시점에 함께 저장해야 한다. 상한(20개 × 500자)이 state 비대를 막는다.
409
+ */
410
+ export interface LastReviewMarker {
411
+ review_kind: ReviewKind
412
+ phase_id: string | null
413
+ outcome: ReviewOutcome
414
+ compare_hash: string | null
415
+ /** 같은 (review_kind, phase_id, compare_hash) 반복 횟수. `blocked_review.count`와 동일 의미론. */
416
+ count: number
417
+ errors: string[]
418
+ at: string
419
+ }
420
+
421
+ /** `last_review.errors` 상한 — state 비대 방지. */
422
+ export const LAST_REVIEW_MAX_ERRORS = 20
423
+ export const LAST_REVIEW_MAX_ERROR_LEN = 500
424
+
425
+ function sameLastReviewTarget(a: LastReviewMarker | undefined, kind: ReviewKind, phaseId: string | null, compareHash: string | null): boolean {
426
+ return !!a && a.review_kind === kind && a.phase_id === phaseId && a.compare_hash === compareHash
427
+ }
428
+
429
+ /**
430
+ * `last_review` 마커 기록(순수). 같은 target이면 `count` 증가, target이 바뀌면 1로 리셋.
431
+ * `errors`는 invalid에서만 저장하고 상한을 적용한다(그 외 outcome은 빈 배열 — findings는 `responses/` 아카이브에 남는다).
432
+ */
433
+ export function recordLastReview(
434
+ state: WorkflowState,
435
+ args: { kind: ReviewKind; phaseId: string | null; outcome: ReviewOutcome; compareHash: string | null; errors: string[]; at: string },
436
+ ): WorkflowState {
437
+ const prev = state.last_review as LastReviewMarker | undefined
438
+ const count = sameLastReviewTarget(prev, args.kind, args.phaseId, args.compareHash) ? prev!.count + 1 : 1
439
+ const errors =
440
+ args.outcome === 'invalid'
441
+ ? args.errors.slice(0, LAST_REVIEW_MAX_ERRORS).map((e) => e.slice(0, LAST_REVIEW_MAX_ERROR_LEN))
442
+ : []
443
+ const marker: LastReviewMarker = {
444
+ review_kind: args.kind,
445
+ phase_id: args.phaseId,
446
+ outcome: args.outcome,
447
+ compare_hash: args.compareHash,
448
+ count,
449
+ errors,
450
+ at: args.at,
451
+ }
452
+ return { ...state, last_review: marker }
453
+ }
454
+
330
455
  export interface WorkflowState {
331
456
  id: string
332
457
  phase: string
@@ -638,12 +763,26 @@ export function resolveReviewOutcome(args: {
638
763
  blockedTarget: BlockedReviewTarget
639
764
  responseSha256: string | null
640
765
  blockedAt: string
766
+ /** REQ-2026-010 D6-2: `req:next`가 읽기 전용으로 재계산할 수 있는 바인딩 해시. 미제공이면 `last_review` 미기록(하위호환). */
767
+ compareHash?: string | null
641
768
  }): { outcome: ReviewOutcome; exitCode: number; finalState: WorkflowState } {
642
769
  const outcome = classifyReview(args.result, args.kind)
643
- const finalState =
770
+ const afterBlocked =
644
771
  outcome === 'blocked'
645
772
  ? recordBlockedReview(args.result.nextState, args.blockedTarget, args.responseSha256, args.blockedAt)
646
773
  : clearBlockedReview(args.result.nextState)
774
+ // last_review는 **모든 outcome**에서 기록한다(approved 포함) — G2가 "직전 리뷰가 이 바인딩을 봤는가"를 알아야 한다.
775
+ const finalState =
776
+ args.compareHash === undefined
777
+ ? afterBlocked
778
+ : recordLastReview(afterBlocked, {
779
+ kind: args.kind,
780
+ phaseId: args.blockedTarget.phase_id,
781
+ outcome,
782
+ compareHash: args.compareHash,
783
+ errors: args.result.errors,
784
+ at: args.blockedAt,
785
+ })
647
786
  return { outcome, exitCode: reviewOutcomeExitCode(outcome), finalState }
648
787
  }
649
788
 
@@ -964,6 +1103,9 @@ function main(): void {
964
1103
  if (!existsSync(requestPath)) throw new Error(`codex-request.md 없음: ${requestPath}`)
965
1104
  const requestBody = readFileSync(requestPath, 'utf8')
966
1105
 
1106
+ // persona: cfg.reviewPersonaPathAbs(null=명시적 비활성). 부재·빈 내용·root 밖 symlink는 **throw**(D3, fail-closed).
1107
+ const persona = loadReviewPersona(cfg.reviewPersonaPathAbs, cfg.root)
1108
+
967
1109
  // handoff: --handoff 우선, 없으면 cfg.handoffPathAbs(null=비활성 — 부재 시 생략, 현재 동작 보존).
968
1110
  const handoffPath = opts.handoff ? resolve(opts.handoff) : cfg.handoffPathAbs
969
1111
  const handoff = handoffPath && existsSync(handoffPath) ? readFileSync(handoffPath, 'utf8') : null
@@ -1016,6 +1158,7 @@ function main(): void {
1016
1158
  previousResult: readPreviousResult(ticketDir),
1017
1159
  }
1018
1160
  const prompt = assembleReviewPrompt({
1161
+ persona,
1019
1162
  handoff,
1020
1163
  reviewContext,
1021
1164
  reviewBaseSha,
@@ -1127,12 +1270,15 @@ function main(): void {
1127
1270
  const responseSha256 = existsSync(respPath)
1128
1271
  ? createHash('sha256').update(readFileSync(respPath)).digest('hex')
1129
1272
  : null
1273
+ // D6-2: req:next가 write-tree 없이 재계산할 수 있는 바인딩 해시. design=designHash, phase=인덱스 전체 해시.
1274
+ const compareHash = opts.kind === 'design' ? designHash ?? null : captureIndexHash()
1130
1275
  const { outcome, exitCode, finalState } = resolveReviewOutcome({
1131
1276
  result,
1132
1277
  kind: opts.kind,
1133
1278
  blockedTarget,
1134
1279
  responseSha256,
1135
1280
  blockedAt: approvedAt,
1281
+ compareHash,
1136
1282
  })
1137
1283
  writeState(ticketDir, finalState)
1138
1284
 
@@ -0,0 +1,15 @@
1
+ # 프로젝트 지침
2
+
3
+ <!-- 이 파일은 `npx commitgate`가 CLAUDE.md가 없을 때만 생성한 템플릿입니다. 자유롭게 수정하세요. -->
4
+
5
+ ## CommitGate
6
+
7
+ 이 저장소는 **CommitGate**를 쓴다. 코드 변경은 REQ 티켓 단위로 묶이고, Codex가 승인한 staged tree만 커밋된다.
8
+
9
+ 코드를 커밋하게 되는 요청은 일반 구현으로 처리하지 말고 이 워크플로를 따른다.
10
+
11
+ - **계약 정본**: 저장소 루트의 [`AGENTS.md`](./AGENTS.md). 절대 규칙·통제점·승인 문장이 거기 있다.
12
+ (`<!-- commitgate:contract -->` 마커가 없으면 CommitGate 계약이 아니다 — init이 함께 설치한 루트의 `AGENTS.commitgate.md`를 계약으로 읽고, 사용자에게 `AGENTS.md`로의 병합을 요청하라.)
13
+ - **다음 행동은 추측하지 않는다**: `npm run req:next -- <REQ-id>`가 알려 준다.
14
+ `RUN`은 그대로 실행, `AGENT`는 그 작업 수행 후 `git add`, `AWAIT_HUMAN`은 **멈추고 승인 문장을 그대로** 받는다.
15
+ - 자세한 진입 절차는 `/req` 슬래시 커맨드 또는 `.claude/skills/commitgate/SKILL.md`에 있다.
@@ -0,0 +1,36 @@
1
+ ---
2
+ description: CommitGate REQ 워크플로로 요구사항을 처리한다 (티켓 발행 → 설계 → Codex 리뷰 → 구현 → 커밋)
3
+ argument-hint: <요구사항>
4
+ ---
5
+
6
+ 이 요청을 일반 구현으로 처리하지 말고, 이 저장소에 설치된 **CommitGate**로 처리하라.
7
+
8
+ ## 요구사항
9
+
10
+ $ARGUMENTS
11
+
12
+ 위 내용이 아래 네 칸으로 정리되지 않으면 **먼저 사용자에게 물어라.** 추측해서 채우지 마라.
13
+
14
+ ```text
15
+ - 무엇을:
16
+ - 왜:
17
+ - 제약:
18
+ - 완료 기준:
19
+ ```
20
+
21
+ ## 계약
22
+
23
+ 저장소 루트의 `AGENTS.md`를 읽어라. 절대 규칙·통제점·승인 문장의 정본이다.
24
+ (`<!-- commitgate:contract -->` 마커가 없으면 init이 함께 설치한 루트의 `AGENTS.commitgate.md`를 계약으로 읽고, 사용자에게 `AGENTS.md`로의 병합을 요청하라.)
25
+
26
+ ## 절차
27
+
28
+ 1. `npm run req:new -- <slug> --run` — 티켓과 브랜치를 만든다.
29
+ 2. 그다음부터는 **`npm run req:next -- <REQ-id>`가 시키는 대로** 한다.
30
+ - `RUN` → 출력된 명령을 그대로 실행 → 다시 `req:next`
31
+ - `AGENT` → 그 작업을 하고 `git add` → 다시 `req:next`
32
+ - `AWAIT_HUMAN` → **멈추고** 출력된 승인 문장을 그대로 받는다
33
+ - `DONE` / `BLOCKED` → 사용자에게 보고
34
+ 3. 이 루프를 끊지 말고 반복한다. 다음 행동을 스스로 추측하지 마라.
35
+
36
+ 첫 응답은 발행한 REQ 번호, 브랜치, phase 분해, 통제점을 요약해서 보여 준다.
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: commitgate
3
+ description: 이 저장소의 REQ 워크플로(CommitGate)로 요구사항을 처리한다. 기능 추가·버그 수정·리팩터링·문서 변경 등 코드를 커밋하게 되는 모든 작업에 사용한다. Codex 리뷰 승인 없이는 커밋이 통과하지 않는다.
4
+ ---
5
+
6
+ # CommitGate
7
+
8
+ 이 저장소는 **CommitGate**를 쓴다. 코드 변경은 REQ 티켓 단위로 묶이고, Codex가 승인한 staged tree만 커밋된다.
9
+
10
+ **일반 구현으로 처리하지 마라.** 아래 계약을 먼저 읽어라.
11
+
12
+ ## 계약 (정본)
13
+
14
+ 저장소 루트의 **`AGENTS.md`**가 정본이다. 절대 규칙, 통제점 표, 승인 문장이 거기 있다.
15
+
16
+ > `AGENTS.md`에 `<!-- commitgate:contract -->` 마커가 없으면 그 파일은 CommitGate 계약이 아니다.
17
+ > 그 경우 `npx commitgate`가 저장소 루트에 **`AGENTS.commitgate.md`**(계약 템플릿 사본)를 함께 설치해 둔다.
18
+ > 그 파일을 계약으로 읽고, 사용자에게 `AGENTS.md`로의 병합을 요청하라.
19
+
20
+ ## 요구사항 받기
21
+
22
+ 사용자 요청이 아래 네 칸으로 정리되지 않으면 먼저 물어라. 추측하지 마라.
23
+
24
+ ```text
25
+ - 무엇을:
26
+ - 왜:
27
+ - 제약:
28
+ - 완료 기준:
29
+ ```
30
+
31
+ 정리되면 `req:new`로 티켓을 만든다.
32
+
33
+ ## 진행 방법 — `req:next`가 시키는 대로
34
+
35
+ **다음 행동을 스스로 추측하지 마라.** 도구가 상태에서 계산해 준다.
36
+
37
+ ```sh
38
+ npm run req:next -- <REQ-id>
39
+ ```
40
+
41
+ 출력의 `kind`가 정본이다(`--json`으로 기계 판독 가능).
42
+
43
+ | kind | 할 일 |
44
+ |---|---|
45
+ | `RUN` | 출력된 명령을 **그대로** 실행하고, 다시 `req:next` |
46
+ | `AGENT` | 도구가 대신 못 하는 작업(구현·문서 작성·`git add`). 하고 나서 다시 `req:next` |
47
+ | `AWAIT_HUMAN` | **멈춘다.** 출력된 승인 문장을 사용자에게서 **그 문장 그대로** 받기 전에는 진행하지 않는다 |
48
+ | `DONE` | 이 티켓에서 도구가 할 일이 없다. 통합은 별도 통제점 |
49
+ | `BLOCKED` | 사람에게 보고한다. 같은 리뷰를 재시도하지 마라 |
50
+
51
+ 이 루프를 **끊지 말고** 반복한다. `AWAIT_HUMAN`·`BLOCKED`·오류에서만 멈춘다.
52
+
53
+ ## 반드시 지킬 것
54
+
55
+ - 리뷰 대상은 `git add` 한 파일뿐이다.
56
+ - `state.json`과 `responses/`는 직접 `git add` 하지 않는다 — 도구가 관리한다.
57
+ - `req:review-codex`가 exit 3(NEEDS_FIX)이면 findings를 수정하고 재리뷰한다.
58
+ - exit 2(BLOCKED)면 **같은 리뷰를 재시도하지 말고** 사람에게 보고한다.
59
+ - 승인은 받은 문장 그대로만 유효하며, 다음 통제점으로 **이월되지 않는다**.
@@ -0,0 +1,55 @@
1
+ ---
2
+ description: CommitGate — 코드 변경은 REQ 티켓 + Codex 리뷰 승인을 거쳐야 커밋된다
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # CommitGate
7
+
8
+ 이 저장소는 **CommitGate**를 쓴다. 코드 변경은 REQ 티켓 단위로 묶이고, Codex가 승인한 staged tree만 커밋된다.
9
+
10
+ 코드를 커밋하게 되는 요청은 **일반 구현으로 처리하지 마라.**
11
+
12
+ ## 계약 (정본)
13
+
14
+ 저장소 루트의 **`AGENTS.md`**가 정본이다. 절대 규칙, 통제점 표, 승인 문장이 거기 있다.
15
+
16
+ > `AGENTS.md`에 `<!-- commitgate:contract -->` 마커가 없으면 그 파일은 CommitGate 계약이 아니다.
17
+ > 그 경우 저장소 루트의 **`AGENTS.commitgate.md`**(init이 함께 설치한 계약 템플릿 사본)를 계약으로 읽고,
18
+ > 사용자에게 `AGENTS.md`로의 병합을 요청하라.
19
+
20
+ ## 요구사항 받기
21
+
22
+ 아래 네 칸이 채워지지 않으면 먼저 물어라. 추측하지 마라.
23
+
24
+ ```text
25
+ - 무엇을:
26
+ - 왜:
27
+ - 제약:
28
+ - 완료 기준:
29
+ ```
30
+
31
+ ## 진행 방법 — `req:next`가 시키는 대로
32
+
33
+ 다음 행동을 스스로 추측하지 마라. 도구가 상태에서 계산한다.
34
+
35
+ ```sh
36
+ npm run req:new -- <slug> --run # 티켓·브랜치 생성
37
+ npm run req:next -- <REQ-id> # 그다음은 항상 이것
38
+ ```
39
+
40
+ | kind | 할 일 |
41
+ |---|---|
42
+ | `RUN` | 출력된 명령을 그대로 실행하고 다시 `req:next` |
43
+ | `AGENT` | 구현·문서 작성·`git add` 후 다시 `req:next` |
44
+ | `AWAIT_HUMAN` | **멈춘다.** 출력된 승인 문장을 그대로 받기 전엔 진행 금지 |
45
+ | `DONE` | 도구가 할 일 없음. 통합은 별도 통제점 |
46
+ | `BLOCKED` | 사람에게 보고. 같은 리뷰 재시도 금지 |
47
+
48
+ 이 루프를 끊지 말고 반복한다.
49
+
50
+ ## 반드시 지킬 것
51
+
52
+ - 리뷰 대상은 `git add` 한 파일뿐이다.
53
+ - `state.json`·`responses/`는 직접 `git add` 하지 않는다.
54
+ - `req:review-codex` exit 3(NEEDS_FIX) → 수정 후 재리뷰. exit 2(BLOCKED) → 재시도 금지, 사람에게 보고.
55
+ - 승인은 받은 문장 그대로만 유효하고 다음 통제점으로 이월되지 않는다.
@@ -5,6 +5,7 @@
5
5
  "ticketRoot": { "type": "string", "minLength": 1 },
6
6
  "schemaPath": { "type": "string", "minLength": 1 },
7
7
  "handoffPath": { "type": ["string", "null"] },
8
+ "reviewPersonaPath": { "type": ["string", "null"], "minLength": 1 },
8
9
  "branchPrefix": { "type": "string", "minLength": 1 },
9
10
  "packageManager": { "type": "string", "enum": ["pnpm", "npm", "yarn"] },
10
11
  "granularityMaxFiles": { "type": "integer", "minimum": 1 },
@@ -0,0 +1,45 @@
1
+ # Reviewer 역할 (PM)
2
+
3
+ 너는 이 프로젝트의 **PM**이다. 이 변경의 **적절성과 완성도**를 확보할 책임이 있다.
4
+
5
+ - Builder가 작성한 리뷰 요청서(`codex-request.md`)의 "리뷰 포인트"는 심사 범위의 **하한**이지 상한이 아니다. 요청서가 묻지 않은 결함도 스스로 분석해 지적하라.
6
+ - Builder가 짜 놓은 리뷰 프레임에 갇히지 마라. 무엇을 봐야 하는지는 네가 판단한다.
7
+ - 개발 부채가 남지 않도록 하라. 지금 넘어가면 나중에 갚아야 하는 것을 식별하라.
8
+
9
+ ## 판정은 구조화 응답 필드로만 낸다
10
+
11
+ | 필드 | 담는 것 |
12
+ |---|---|
13
+ | `findings[]` | **이 변경을 지금 커밋하면 안 되는 이유**만 |
14
+ | `observations[]` | 비차단 의견 — 스타일 취향, 범위 밖 개선, 후속 티켓 후보 |
15
+ | `next_action` | Builder가 다음에 할 일 |
16
+ | `status` / `commit_approved` | 게이트 판정 |
17
+
18
+ ## 승인 규칙 (어기면 워크플로가 응답을 거부한다)
19
+
20
+ **승인(`commit_approved=yes`)은 `findings`가 0건일 때만 가능하다.** 지적이 하나라도 있으면 승인할 수 없다 — 워크플로가 그 모순을 검출해 응답 전체를 무효 처리한다.
21
+
22
+ 그래서 `findings`에 무엇을 넣을지가 곧 승인 여부다.
23
+
24
+ - **`findings`에 넣을 것**: 정확성 결함, 안전·보안 구멍, fail-closed 우회, 계약 위반, 범위 이탈, 검증 누락 — 지금 커밋되면 안 되는 것.
25
+ - **`observations`에 넣을 것**: 이름·주석·구조 취향, 후속 리팩터 제안, 이 phase 범위를 넘는 개선, "나중에 보면 좋을 것".
26
+
27
+ **"개발 부채가 남지 않도록 하라"를 "부채 후보를 전부 `findings`로 올리라"로 읽지 마라.** 지금 넘어가도 되는 부채는 `observations`에 기록해 다음 티켓의 입력으로 만든다. 차단과 비차단의 경계를 흐리면 승인이 영영 나지 않고, 그것은 리뷰의 실패다.
28
+
29
+ `observations`에는 `severity`를 붙이지 않는다. severity가 붙는 순간 차단 신호가 되어 경계가 무너진다.
30
+
31
+ ## 지적의 형태
32
+
33
+ - 결함마다 **재현 경로 또는 실패 시나리오**를 적어라. "이 입력/상태에서 이 결과가 나온다."
34
+ - 파일이 특정되면 `file`에 적어라. 전역 지적이면 비워도 된다.
35
+ - 추측이면 추측이라고 말하고 `observations`로 내려라. 확신하는 것만 `findings`에 올린다.
36
+ - 결함이 없으면 `findings` 없이 승인하라. 하고 싶은 말은 `observations`에 남긴다.
37
+
38
+ ## 리뷰 대상
39
+
40
+ 리뷰 종류는 프롬프트의 **REVIEW_KIND**를 따른다.
41
+
42
+ - `design` — 권위 아티팩트는 설계 문서 3종이다. 구현 diff가 없는 것이 정상이다. 설계의 결함·누락·모순을 본다.
43
+ - `phase` — 권위 아티팩트는 staged diff다. 그 diff만 심사한다.
44
+
45
+ 리뷰 대상이 아닌 것을 근거로 지적하지 마라. 설계 리뷰에서 "구현이 없다"는 지적은 성립하지 않는다.