commitgate 0.2.0 → 0.2.2

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.
@@ -9,7 +9,7 @@
9
9
  * 본 모듈은 req 스크립트에 의존하지 않는 leaf(순환 의존 금지) — parseThreadId도 여기로 이동(codex CLI 전용 로직).
10
10
  */
11
11
  import { execFileSync } from 'node:child_process'
12
- import { existsSync, readFileSync, mkdtempSync } from 'node:fs'
12
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
  import { tmpdir } from 'node:os'
15
15
  import spawn from 'cross-spawn'
@@ -54,15 +54,19 @@ export interface GitAdapter {
54
54
  }
55
55
 
56
56
  /** GitAdapter default 구현의 내부 실행자(주입 가능 — 테스트). encoding:'utf8'이라 string 반환. */
57
- export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8' }) => string
57
+ export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8'; maxBuffer: number }) => string
58
58
  const defaultGitRunner: GitRunner = (file, args, opts) => execFileSync(file, args, opts)
59
59
 
60
+ /** git stdout 상한 — codex 경로(safeSpawnSync)와 동일 64 MiB. 큰 staged diff/status에서 Node 기본 1 MiB의 ENOBUFS throw 방지. */
61
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024
62
+
60
63
  /**
61
- * default GitAdapter — `execFileSync('git', args, {cwd: root, encoding:'utf8'})` 후 **trailing 공백만 제거**.
64
+ * default GitAdapter — `execFileSync('git', args, {cwd: root, encoding:'utf8', maxBuffer})` 후 **trailing 공백만 제거**.
62
65
  * ⚠️ `.trim()`이 아니라 `.replace(/\s+$/, '')` — `git status --porcelain`의 **선행 공백(XY 코드)**을 보존(behavior-preserving).
66
+ * maxBuffer=64MiB: `git diff --cached`/`git show :<path>`가 1 MiB 기본 상한을 넘겨 codex 호출 전에 하드 실패하는 것 방지.
63
67
  */
64
68
  export function createGitAdapter(root: string, run: GitRunner = defaultGitRunner): GitAdapter {
65
- return { exec: (args) => run('git', args, { cwd: root, encoding: 'utf8' }).replace(/\s+$/, '') }
69
+ return { exec: (args) => run('git', args, { cwd: root, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER }).replace(/\s+$/, '') }
66
70
  }
67
71
 
68
72
  // ─────────────────────────────────────────────── Reviewer (codex) ──
@@ -105,19 +109,42 @@ const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
105
109
  // ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId 등)의 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 — P1 수정.
106
110
  safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
107
111
 
112
+ /**
113
+ * Codex `--output-schema`용 **strict copy** 파생(REQ-2026-005). OpenAI structured-outputs strict mode는
114
+ * root `required`가 `properties`의 **모든 키**를 포함해야 한다 — optional 필드(예: observations)가 있으면 400 invalid_json_schema.
115
+ * 원본 스키마(`workflow/machine.schema.json`)는 **검증 SSOT로 불변**(observations optional → 기존 archive 하위호환)이고,
116
+ * codex 호출 직전에만 root.required를 `properties` 전체로 확장한 copy를 파생한다. 응답/archive 검증은 계속 원본으로 한다.
117
+ * 중첩 객체(findings/observations items)는 이미 모든 필드 required + additionalProperties:false라 root만 확장하면 충분.
118
+ */
119
+ export function deriveStrictOutputSchema(schemaText: string): string {
120
+ const schema = JSON.parse(schemaText) as { properties?: Record<string, unknown>; required?: string[]; [k: string]: unknown }
121
+ if (schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object') {
122
+ schema.required = Object.keys(schema.properties)
123
+ }
124
+ return JSON.stringify(schema)
125
+ }
126
+
108
127
  /**
109
128
  * default ReviewerAdapter(codex CLI). exec(신규)/resume(thread_id) 분기 + `--output-last-message`(임시파일) 캡처 + thread 파싱.
110
- * - resume은 `--sandbox` 미수용(0차 실측) → 생략(정책 상속). exec은 `--sandbox read-only`.
129
+ * - **read-only 리뷰어 강제( 라운드, REQ-2026-006/R9)**: exec은 `--sandbox read-only`. resume은 `-s/--sandbox` 플래그를 **거부**하므로
130
+ * (`error: unexpected argument '--sandbox'` — spike 확인) `-c sandbox_mode="read-only"` **config override**로 read-only를 강제한다.
131
+ * 행동 검증: resume(무 override)은 실제 write 성공(sandbox drop=갭 실재), resume(override)은 write가 `Access is denied`로 차단(enforced).
132
+ * `-c`가 향후 CLI에서 거부되면 resume 자체가 실패=fail-closed(리뷰 미승인).
133
+ * - `--output-schema`에는 원본이 아니라 **strict 파생 copy**를 넘긴다(codex strict mode 400 방지, REQ-2026-005). 원본은 검증 SSOT.
111
134
  * - threadId: resume이면 resumeThreadId, exec이면 parseThreadId(stdout)(없으면 null → 호출처가 fail-closed).
112
135
  * - codex 미설치/실패 → runner가 throw(그대로 전파, fail-closed).
113
136
  */
114
137
  export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner): ReviewerAdapter {
115
138
  return {
116
139
  review({ prompt, schemaPath, resumeThreadId, cwd }) {
117
- const lastPath = join(mkdtempSync(join(tmpdir(), 'req-codex-')), 'last.json')
140
+ const tmpDir = mkdtempSync(join(tmpdir(), 'req-codex-'))
141
+ const lastPath = join(tmpDir, 'last.json')
142
+ // 원본(검증 SSOT)을 읽어 strict copy 파생 → temp에 기록 → --output-schema로 전달(archive 검증엔 원본 사용).
143
+ const outputSchemaPath = join(tmpDir, 'output-schema.json')
144
+ writeFileSync(outputSchemaPath, deriveStrictOutputSchema(readFileSync(schemaPath, 'utf8')), 'utf8')
118
145
  const args = resumeThreadId
119
- ? ['exec', 'resume', resumeThreadId, '--json', '--output-schema', schemaPath, '--output-last-message', lastPath, '-']
120
- : ['exec', '--json', '--sandbox', 'read-only', '--output-schema', schemaPath, '--output-last-message', lastPath, '-']
146
+ ? ['exec', 'resume', resumeThreadId, '-c', 'sandbox_mode="read-only"', '--json', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
147
+ : ['exec', '--json', '--sandbox', 'read-only', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
121
148
  const rawStdout = run(args, prompt, cwd)
122
149
  const threadId = resumeThreadId ?? parseThreadId(rawStdout)
123
150
  const lastMessage = existsSync(lastPath) ? readFileSync(lastPath, 'utf8') : ''
@@ -191,13 +191,22 @@ export function readDesignDocsFromIndex(
191
191
  // 구조(필수 키·enum)는 단계 3에서 AJV(machine.schema.json)로 강제.
192
192
  // 여기서는 AJV가 표현 못 하는 교차필드 모순·버전·git 바인딩을 fail-closed로 검사(§9.6 rule 2~3).
193
193
 
194
- /** 리뷰 지적 항목 (machine.schema.json 1.1 findings[]). file은 nullable(전역 지적 등). */
194
+ /** 리뷰 지적 항목 (machine.schema.json 1.1 findings[]). file은 nullable(전역 지적 등). severity=blocking 신호. */
195
195
  export interface Finding {
196
196
  severity?: string
197
197
  detail?: string
198
198
  file?: string | null
199
199
  }
200
200
 
201
+ /**
202
+ * 비차단 코멘트(observations[], REQ-2026-005). 승인/차단 판정에 영향 없음(순수 정보).
203
+ * **severity 없음** — severity가 붙는 순간 blocking(findings)/non-blocking(observations) 경계가 흐려진다(스키마가 구조적으로 거부).
204
+ */
205
+ export interface Observation {
206
+ detail?: string
207
+ file?: string | null
208
+ }
209
+
201
210
  export interface Verdict {
202
211
  machine_schema_version?: string
203
212
  review_base_sha?: string
@@ -208,6 +217,8 @@ export interface Verdict {
208
217
  review_kind?: string
209
218
  findings?: Finding[]
210
219
  next_action?: string
220
+ // REQ-2026-005: optional 비차단 코멘트. classifyReview는 이 필드를 보지 않는다(findings 존재만으로 분류).
221
+ observations?: Observation[]
211
222
  }
212
223
 
213
224
  const STATUS_VALUES = ['NEEDS_FIX', 'STEP_COMPLETE', 'COMPLETE']
@@ -253,6 +264,12 @@ export function validateVerdict(
253
264
  if (v.merge_ready === 'yes' && v.commit_approved !== 'yes')
254
265
  errors.push('모순: merge_ready=yes 인데 commit_approved≠yes')
255
266
 
267
+ // R10 (safety, fail-closed): 승인(commit_approved=yes)은 findings가 0건이어야 한다.
268
+ // findings는 **승인 차단 신호**이므로, 지적이 있는데 승인은 모순 — 미검토/미조치 코드가 승인되는 구멍을 막는다.
269
+ // 비차단 코멘트가 필요하면 findings가 아니라 별도 필드(예: observations)를 도입해야 한다(findings 오버로드 금지).
270
+ if (v.commit_approved === 'yes' && Array.isArray(v.findings) && v.findings.length > 0)
271
+ errors.push('모순: commit_approved=yes 인데 findings가 비어있지 않음 (승인은 findings 0건 — 지적이 있으면 미승인)')
272
+
256
273
  return { ok: errors.length === 0, errors }
257
274
  }
258
275
 
@@ -283,6 +300,35 @@ export interface ApprovalEvidence {
283
300
  approved_at: string
284
301
  }
285
302
 
303
+ export type ReviewOutcome = 'approved' | 'needs-fix' | 'blocked' | 'invalid'
304
+
305
+ export const REVIEW_EXIT_CODES: Record<ReviewOutcome, number> = {
306
+ approved: 0,
307
+ invalid: 1,
308
+ blocked: 2,
309
+ 'needs-fix': 3,
310
+ }
311
+
312
+ export interface ProcessResponseResult {
313
+ ok: boolean
314
+ errors: string[]
315
+ nextState: WorkflowState
316
+ verdict: Verdict
317
+ }
318
+
319
+ export interface BlockedReviewTarget {
320
+ review_kind: ReviewKind
321
+ phase_id: string | null
322
+ review_base_sha: string
323
+ review_binding: string
324
+ }
325
+
326
+ export interface BlockedReviewMarker extends BlockedReviewTarget {
327
+ count: number
328
+ response_sha256: string | null
329
+ blocked_at: string
330
+ }
331
+
286
332
  export interface WorkflowState {
287
333
  id: string
288
334
  phase: string
@@ -295,6 +341,7 @@ export interface WorkflowState {
295
341
  // REQ-016 A1: 승인 증거 핀(kind 격리) + grandfathering 트리거. 반대 kind 증거는 미오염.
296
342
  approval_evidence?: ApprovalEvidence
297
343
  design_approval_evidence?: ApprovalEvidence
344
+ blocked_review?: BlockedReviewMarker
298
345
  approval_evidence_required?: boolean
299
346
  [k: string]: unknown
300
347
  }
@@ -464,7 +511,7 @@ export function processResponse(args: {
464
511
  // REQ-016 A1: 승인 시 증거 핀 정본(아카이브 경로+sha). 미제공 시 evidence 미부착(하위호환).
465
512
  archive?: { path: string; sha256: string }
466
513
  approvedAt?: string
467
- }): { ok: boolean; errors: string[]; nextState: WorkflowState } {
514
+ }): ProcessResponseResult {
468
515
  const { ticketDir, state, binding, threadId, designHash, schemaPath } = args
469
516
  const kind: ReviewKind = args.kind ?? 'phase'
470
517
  const respPath = join(ticketDir, 'codex-response.json')
@@ -478,6 +525,10 @@ export function processResponse(args: {
478
525
 
479
526
  const struct = validateResponseStructure(verdict, schemaPath ?? MACHINE_SCHEMA_PATH)
480
527
  const domain = validateVerdict(verdict, { reviewBaseSha: binding.reviewBaseSha })
528
+ // REQ-2026-005 defaulting layer: **검증(원본, observations optional) 이후** 결측/비배열 observations를 []로 정규화한다.
529
+ // strict 출력 스키마(codex는 항상 observations emit)와 optional 검증 스키마(구 archive는 결측 허용) 사이의 계약을 내부적으로 일관화 —
530
+ // 하류(classifyReview·표출·evidence)는 observations를 **항상 배열**로 취급. 검증 전에 하지 않는 이유: 비배열(파손) observations는 AJV가 먼저 invalid로 잡아야 하기 때문.
531
+ verdict.observations = Array.isArray(verdict.observations) ? verdict.observations : []
481
532
  const kindMismatch = verdict.review_kind !== kind
482
533
  // design 승인엔 non-null designHash 필수(freshness anchor) — 누락은 fail-closed(Codex P3).
483
534
  const designHashMissing = kind === 'design' && !(typeof designHash === 'string' && designHash.trim().length > 0)
@@ -510,7 +561,7 @@ export function processResponse(args: {
510
561
  approvedAt: args.approvedAt ?? new Date().toISOString(),
511
562
  })
512
563
  }
513
- return { ok, errors, nextState }
564
+ return { ok, errors, nextState, verdict }
514
565
  }
515
566
 
516
567
  // A1-P2-1: 같은 kind(phase)의 기존 증거는 항상 stale로 보고 제거(base에서 omit). 반대 kind(design)는 보존.
@@ -539,7 +590,7 @@ export function processResponse(args: {
539
590
  })
540
591
  }
541
592
 
542
- return { ok, errors, nextState }
593
+ return { ok, errors, nextState, verdict }
543
594
  }
544
595
 
545
596
  /** state.json 기록 — UTF-8(BOM 없음), 2-space + 끝 개행. */
@@ -547,6 +598,115 @@ export function writeState(ticketDir: string, state: WorkflowState): void {
547
598
  writeFileSync(join(ticketDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
548
599
  }
549
600
 
601
+ function verdictHasFindings(verdict: Verdict): boolean {
602
+ return Array.isArray(verdict.findings) && verdict.findings.length > 0
603
+ }
604
+
605
+ function isOutcomeApproved(result: ProcessResponseResult, kind: ReviewKind): boolean {
606
+ return kind === 'phase' ? result.nextState.commit_allowed === true : result.nextState.design_approved === true
607
+ }
608
+
609
+ /**
610
+ * 검증 유효성(result.ok)과 게이트 승인 여부를 분리한 최종 리뷰 outcome.
611
+ * - invalid ⟺ !result.ok (구조/도메인 검증 실패). errors[]가 진단 정본.
612
+ * - approved = 게이트 승인(phase=commit_allowed·design=design_approved).
613
+ * - needs-fix = 유효·미승인·**조치할 findings 있음**(NEEDS_FIX든 STEP_COMPLETE든 findings가 있으면 여기 — 조치 가능하므로).
614
+ * - blocked = 유효·미승인·**findings 없음**(결정적 고착 — 같은 바인딩 재리뷰는 순수 낭비).
615
+ * ⚠️ status 기반이 아니라 **findings 존재** 기반으로 분기 — "미승인인데 findings=[]"만 blocked로 격리해야
616
+ * (a) 조치할 게 있는 verdict가 진단 없이 invalid로 새는 것을 막고 (b) blocked 회로차단기가 오탐 없이 동작한다.
617
+ * (NEEDS_FIX + findings=[]는 validateVerdict가 invalid로 잡으므로 여기 도달하지 않는다.)
618
+ */
619
+ export function classifyReview(result: ProcessResponseResult, kind: ReviewKind): ReviewOutcome {
620
+ if (!result.ok) return 'invalid'
621
+ if (isOutcomeApproved(result, kind)) return 'approved'
622
+ if (verdictHasFindings(result.verdict)) return 'needs-fix'
623
+ return 'blocked'
624
+ }
625
+
626
+ export function reviewOutcomeExitCode(outcome: ReviewOutcome): number {
627
+ return REVIEW_EXIT_CODES[outcome]
628
+ }
629
+
630
+ /**
631
+ * 리뷰 결과 → 최종 outcome·종료코드·기록할 state(순수). main() 종료 배선의 **단일 정본**
632
+ * (main이 이 함수를 호출하므로 병렬 재구현으로 인한 drift가 없다 — exit-code 계약을 테스트로 고정 가능).
633
+ * - outcome = classifyReview
634
+ * - blocked → 회로차단기 마커 기록(같은 target이면 count 증가), 그 외 → 마커 제거(clear)
635
+ * - exitCode: approved=0 · invalid=1 · blocked=2 · needs-fix=3
636
+ */
637
+ export function resolveReviewOutcome(args: {
638
+ result: ProcessResponseResult
639
+ kind: ReviewKind
640
+ blockedTarget: BlockedReviewTarget
641
+ responseSha256: string | null
642
+ blockedAt: string
643
+ }): { outcome: ReviewOutcome; exitCode: number; finalState: WorkflowState } {
644
+ const outcome = classifyReview(args.result, args.kind)
645
+ const finalState =
646
+ outcome === 'blocked'
647
+ ? recordBlockedReview(args.result.nextState, args.blockedTarget, args.responseSha256, args.blockedAt)
648
+ : clearBlockedReview(args.result.nextState)
649
+ return { outcome, exitCode: reviewOutcomeExitCode(outcome), finalState }
650
+ }
651
+
652
+ export function buildBlockedReviewTarget(args: {
653
+ kind: ReviewKind
654
+ phaseId: string | null
655
+ binding: Binding
656
+ designHash?: string | null
657
+ }): BlockedReviewTarget {
658
+ return {
659
+ review_kind: args.kind,
660
+ phase_id: args.kind === 'phase' ? args.phaseId ?? null : null,
661
+ review_base_sha: args.binding.reviewBaseSha,
662
+ review_binding: args.kind === 'design' ? args.designHash ?? args.binding.reviewTree : args.binding.reviewTree,
663
+ }
664
+ }
665
+
666
+ function sameBlockedReviewTarget(a: BlockedReviewMarker | undefined, b: BlockedReviewTarget): boolean {
667
+ return (
668
+ !!a &&
669
+ a.review_kind === b.review_kind &&
670
+ a.phase_id === b.phase_id &&
671
+ a.review_base_sha === b.review_base_sha &&
672
+ a.review_binding === b.review_binding
673
+ )
674
+ }
675
+
676
+ export function shouldShortCircuitBlockedReview(
677
+ state: WorkflowState,
678
+ target: BlockedReviewTarget,
679
+ limit = 2,
680
+ ): boolean {
681
+ const marker = state.blocked_review
682
+ if (!marker || !sameBlockedReviewTarget(marker, target)) return false
683
+ return marker.count >= limit
684
+ }
685
+
686
+ export function recordBlockedReview(
687
+ state: WorkflowState,
688
+ target: BlockedReviewTarget,
689
+ responseSha256: string | null,
690
+ blockedAt: string,
691
+ ): WorkflowState {
692
+ const marker = state.blocked_review
693
+ const count = marker && sameBlockedReviewTarget(marker, target) ? marker.count + 1 : 1
694
+ return {
695
+ ...state,
696
+ blocked_review: {
697
+ ...target,
698
+ count,
699
+ response_sha256: responseSha256,
700
+ blocked_at: blockedAt,
701
+ },
702
+ }
703
+ }
704
+
705
+ export function clearBlockedReview(state: WorkflowState): WorkflowState {
706
+ const { blocked_review: _blocked, ...rest } = state
707
+ return rest
708
+ }
709
+
550
710
  // parseThreadId는 lib/adapters로 이동(codex CLI 전용 로직) — 상단에서 re-export.
551
711
 
552
712
  /**
@@ -668,13 +828,48 @@ export function buildApprovalEvidence(args: {
668
828
  * 유효 + 승인(phase=commit_allowed·design=design_approved) → 'approved', 그 외(유효 NEEDS_FIX) → 'needs-fix'.
669
829
  */
670
830
  export function archiveDecision(
671
- result: { ok: boolean; nextState: WorkflowState },
831
+ result: ProcessResponseResult,
672
832
  kind: ReviewKind,
673
833
  ): 'approved' | 'needs-fix' | null {
674
- if (!result.ok) return null
675
- const approved =
676
- kind === 'phase' ? result.nextState.commit_allowed === true : result.nextState.design_approved === true
677
- return approved ? 'approved' : 'needs-fix'
834
+ const outcome = classifyReview(result, kind)
835
+ if (outcome === 'approved') return 'approved'
836
+ if (outcome === 'needs-fix') return 'needs-fix'
837
+ return null
838
+ }
839
+
840
+ function outcomeLabel(outcome: ReviewOutcome): string {
841
+ if (outcome === 'needs-fix') return 'NEEDS_FIX'
842
+ return outcome.toUpperCase()
843
+ }
844
+
845
+ /** 비차단 코멘트(observations) 표출 — REQ-2026-005. 승인에서도 보이게(사용자가 놓치지 않게). 판정엔 영향 없음. */
846
+ function printObservations(verdict: Verdict): void {
847
+ const obs = Array.isArray(verdict.observations) ? verdict.observations : []
848
+ if (!obs.length) return
849
+ console.error(' observations (non-blocking):')
850
+ for (const o of obs) {
851
+ const where = o.file ? ` ${o.file}` : ''
852
+ console.error(` -${where}: ${o.detail ?? ''}`)
853
+ }
854
+ }
855
+
856
+ function printOutcomeDetails(outcome: ReviewOutcome, result: ProcessResponseResult): void {
857
+ if (outcome === 'needs-fix') {
858
+ for (const f of result.verdict.findings ?? []) {
859
+ const where = f.file ? ` ${f.file}` : ''
860
+ console.error(` - ${f.severity ?? 'P?'}${where}: ${f.detail ?? ''}`)
861
+ }
862
+ if (typeof result.verdict.next_action === 'string' && result.verdict.next_action.trim())
863
+ console.error(` next_action: ${result.verdict.next_action}`)
864
+ } else if (outcome === 'blocked') {
865
+ console.error(' - Codex returned no actionable findings but did not approve the gate.')
866
+ console.error(' - Do not retry the same review without changing the binding or escalating to a human.')
867
+ } else if (outcome === 'invalid') {
868
+ for (const e of result.errors) console.error(` - ${e}`)
869
+ return // invalid는 errors만 — observations 미표출
870
+ }
871
+ // approved/needs-fix/blocked에서 비차단 코멘트 표출(특히 approved에서 사용자가 코멘트를 놓치지 않게).
872
+ printObservations(result.verdict)
678
873
  }
679
874
 
680
875
  // ──────────────────────────────────────────────────────────────── CLI ──
@@ -687,11 +882,15 @@ export interface Opts {
687
882
  kind: ReviewKind
688
883
  phase: string | null
689
884
  root: string | null
885
+ freshThread: boolean
690
886
  }
691
887
 
692
- /** CLI 파싱. `--kind design|phase`(기본 phase, 하위호환)·`--phase <id>`(phase kind 대상). 잘못된 --kind/--phase 값은 fail-closed throw. */
888
+ /**
889
+ * CLI 파싱. `--kind design|phase`(기본 phase, 하위호환)·`--phase <id>`(phase kind 대상). 잘못된 --kind/--phase 값은 fail-closed throw.
890
+ * `--fresh-thread`: blocked 회로차단기의 명시적 회복 경로 — blocked_review 마커를 초기화하고 codex 스레드를 새로 시작(고착된 resume 스레드를 끊는다).
891
+ */
693
892
  export function parseArgs(argv: string[]): Opts {
694
- const opts: Opts = { ticket: null, reqId: null, handoff: null, run: false, kind: 'phase', phase: null, root: null }
893
+ const opts: Opts = { ticket: null, reqId: null, handoff: null, run: false, kind: 'phase', phase: null, root: null, freshThread: false }
695
894
  for (let i = 0; i < argv.length; i++) {
696
895
  const a = argv[i]
697
896
  if (a === undefined) continue
@@ -713,6 +912,7 @@ export function parseArgs(argv: string[]): Opts {
713
912
  opts.phase = v
714
913
  } else if (a === '--run') opts.run = true // 라이브 codex 호출(미지정 시 dry-run 미리보기)
715
914
  else if (a === '--dry-run') opts.run = false
915
+ else if (a === '--fresh-thread') opts.freshThread = true // blocked 회복: 마커 초기화 + 새 스레드
716
916
  else if (!a.startsWith('-')) opts.reqId = a
717
917
  }
718
918
  return opts
@@ -758,7 +958,9 @@ function main(): void {
758
958
  const cfg = loadConfig({ root: opts.root })
759
959
  gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
760
960
  const ticketDir = resolveTicketDir(opts, cfg)
761
- const state = loadState(ticketDir) // 부재 시 명확한 에러
961
+ let state = loadState(ticketDir) // 부재 시 명확한 에러
962
+ // --fresh-thread: blocked 회로차단기 명시적 회복 — 마커 제거(단락 해제) + resume 대신 새 스레드(고착 resume 끊기).
963
+ if (opts.freshThread) state = clearBlockedReview(state)
762
964
 
763
965
  const requestPath = join(ticketDir, 'codex-request.md')
764
966
  if (!existsSync(requestPath)) throw new Error(`codex-request.md 없음: ${requestPath}`)
@@ -801,6 +1003,12 @@ function main(): void {
801
1003
  } else {
802
1004
  stagedDiff = git(['diff', '--cached'])
803
1005
  }
1006
+ const blockedTarget = buildBlockedReviewTarget({
1007
+ kind: opts.kind,
1008
+ phaseId,
1009
+ binding: { reviewBaseSha, reviewTree },
1010
+ designHash,
1011
+ })
804
1012
 
805
1013
  const reviewContext: ReviewContext = {
806
1014
  branch,
@@ -839,7 +1047,8 @@ function main(): void {
839
1047
  // 워크플로 도구가 쓰는 메타데이터(응답·프리뷰·state)는 리뷰 대상 아님 → exact 경로로 허용(precondition/무수정검증 제외).
840
1048
  // 특히 state.json은 직전 라운드 writeState로 unstaged가 되므로 resume(2회차) precondition 통과에 필수(4C e2e 발견).
841
1049
  const SCRATCH = [repoRel(respPath), repoRel(previewPath), repoRel(join(ticketDir, 'state.json'))]
842
- const isResume = typeof state.codex_thread_id === 'string' && state.codex_thread_id.length > 0
1050
+ // --fresh-thread면 codex_thread_id가 있어도 resume하지 않고 exec으로 시작(고착 스레드 회복).
1051
+ const isResume = !opts.freshThread && typeof state.codex_thread_id === 'string' && state.codex_thread_id.length > 0
843
1052
 
844
1053
  // Phase 4: 추적 phase는 유효 design 승인 전제(D13 동일) — 미충족 시 호출 전 fail-closed(불필요 codex 호출 방지).
845
1054
  if (phaseId && !designValid)
@@ -855,6 +1064,12 @@ function main(): void {
855
1064
  `리뷰 전 워킹트리에 unstaged/untracked 존재(D10) — 의도 변경은 git add, 그 외 정리 필요:\n ${preDirty.join('\n ')}`,
856
1065
  )
857
1066
 
1067
+ if (shouldShortCircuitBlockedReview(state, blockedTarget)) {
1068
+ console.error('[req:review-codex] BLOCKED repeated blocked result for the same review binding')
1069
+ console.error(' Codex will not be called again for this unchanged target. Change the staged binding or escalate.')
1070
+ process.exit(reviewOutcomeExitCode('blocked'))
1071
+ }
1072
+
858
1073
  console.warn(`⚠️ codex 실제 호출 (${isResume ? 'resume' : 'exec'}) — 호출 1회 발생 (DEC-WF-026: 호출 직전 확인)`)
859
1074
 
860
1075
  // ReviewerAdapter 경유(Phase 3). exec/resume 분기·--output-last-message·thread 파싱은 어댑터가 담당.
@@ -911,16 +1126,24 @@ function main(): void {
911
1126
  decision === 'approved' && archiveDesc
912
1127
  ? processResponse({ ...baseArgs, archive: archiveDesc, approvedAt })
913
1128
  : probe
914
- writeState(ticketDir, result.nextState)
1129
+ const responseSha256 = existsSync(respPath)
1130
+ ? createHash('sha256').update(readFileSync(respPath)).digest('hex')
1131
+ : null
1132
+ const { outcome, exitCode, finalState } = resolveReviewOutcome({
1133
+ result,
1134
+ kind: opts.kind,
1135
+ blockedTarget,
1136
+ responseSha256,
1137
+ blockedAt: approvedAt,
1138
+ })
1139
+ writeState(ticketDir, finalState)
915
1140
 
916
- console.log(`[req:review-codex] ${result.ok ? 'OK' : 'FAIL(fail-closed)'} thread=${threadId}`)
1141
+ console.log(`[req:review-codex] ${outcomeLabel(outcome)} thread=${threadId}`)
917
1142
  console.log(
918
- ` commit_allowed=${String(result.nextState.commit_allowed)} approved=${String(result.nextState.approved_diff_hash ?? 'null')}`,
1143
+ ` commit_allowed=${String(finalState.commit_allowed)} approved=${String(finalState.approved_diff_hash ?? 'null')}`,
919
1144
  )
920
- if (!result.ok) {
921
- for (const e of result.errors) console.error(` - ${e}`)
922
- process.exit(1)
923
- }
1145
+ printOutcomeDetails(outcome, result)
1146
+ if (exitCode !== 0) process.exit(exitCode)
924
1147
  }
925
1148
 
926
1149
  const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
@@ -16,7 +16,11 @@
16
16
  "machine_schema_version": { "type": "string", "enum": ["1.1"] },
17
17
  "review_base_sha": { "type": "string" },
18
18
  "status": { "type": "string", "enum": ["NEEDS_FIX", "STEP_COMPLETE", "COMPLETE"] },
19
- "commit_approved": { "type": "string", "enum": ["yes", "no"] },
19
+ "commit_approved": {
20
+ "type": "string",
21
+ "enum": ["yes", "no"],
22
+ "description": "Gate approval for this review. For review_kind=design, yes means the design is approved to proceed (nothing is committed); approve a good design with commit_approved=yes and merge_ready=no. For review_kind=phase, yes means the staged tree is approved to commit. Approve (yes) ONLY when findings is empty: any finding is a blocking signal, so commit_approved=yes with a non-empty findings array is a contradiction and is rejected. Conversely, returning no with an empty findings array is also a contradiction and is rejected as BLOCKED — if you have no findings you must approve. Minor/non-blocking remarks do NOT belong in findings; put them in observations and still approve."
23
+ },
20
24
  "merge_ready": { "type": "string", "enum": ["yes", "no"] },
21
25
  "risk_level": { "type": "string", "enum": ["LOW", "HIGH"] },
22
26
  "review_kind": { "type": "string", "enum": ["design", "phase"] },
@@ -33,6 +37,19 @@
33
37
  }
34
38
  }
35
39
  },
36
- "next_action": { "type": "string" }
40
+ "next_action": { "type": "string" },
41
+ "observations": {
42
+ "type": "array",
43
+ "description": "Optional non-blocking comments that do NOT block approval or gate the commit. Use this (NOT findings) for minor/nit remarks when approving. Items carry detail and file only — deliberately NO severity (severity belongs to blocking findings). commit_approved=yes with findings=[] and observations present is still approved. observations never substitute for findings: commit_approved=no with findings=[] and observations only is still BLOCKED.",
44
+ "items": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": ["detail", "file"],
48
+ "properties": {
49
+ "detail": { "type": "string" },
50
+ "file": { "type": ["string", "null"] }
51
+ }
52
+ }
53
+ }
37
54
  }
38
55
  }