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.
- package/AGENTS.template.md +51 -48
- package/README.en.md +246 -217
- package/README.md +246 -217
- package/bin/commitgate.mjs +2 -1
- package/bin/init.ts +40 -6
- package/package.json +1 -1
- package/scripts/req/lib/adapters.ts +35 -8
- package/scripts/req/review-codex.ts +243 -20
- package/workflow/machine.schema.json +19 -2
|
@@ -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
|
-
* -
|
|
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
|
|
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',
|
|
120
|
-
: ['exec', '--json', '--sandbox', 'read-only', '--output-schema',
|
|
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
|
-
}):
|
|
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:
|
|
831
|
+
result: ProcessResponseResult,
|
|
672
832
|
kind: ReviewKind,
|
|
673
833
|
): 'approved' | 'needs-fix' | null {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ${
|
|
1141
|
+
console.log(`[req:review-codex] ${outcomeLabel(outcome)} thread=${threadId}`)
|
|
917
1142
|
console.log(
|
|
918
|
-
` commit_allowed=${String(
|
|
1143
|
+
` commit_allowed=${String(finalState.commit_allowed)} approved=${String(finalState.approved_diff_hash ?? 'null')}`,
|
|
919
1144
|
)
|
|
920
|
-
|
|
921
|
-
|
|
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": {
|
|
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
|
}
|