commitgate 0.1.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.
- package/AGENTS.template.md +48 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/commitgate.mjs +21 -0
- package/bin/init.ts +328 -0
- package/package.json +62 -0
- package/req.config.json.sample +13 -0
- package/scripts/req/lib/adapters.ts +139 -0
- package/scripts/req/lib/config.ts +177 -0
- package/scripts/req/req-commit.ts +792 -0
- package/scripts/req/req-doctor.ts +526 -0
- package/scripts/req/req-new.ts +173 -0
- package/scripts/req/review-codex.ts +927 -0
- package/workflow/machine.schema.json +38 -0
- package/workflow/req.config.schema.json +21 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* req:review-codex — AI REQ 워크플로우 1차 (단계 2: 조립·바인딩 캡처 + 기반 검증 로직)
|
|
4
|
+
*
|
|
5
|
+
* SSOT 설계: palm-kiosk/docs/evaluation/ai-req-workflow-design.md
|
|
6
|
+
* §9.5 호출 문법 · §8.4 staged tree OID 바인딩 · §9.6 구조화 응답·도메인 검증
|
|
7
|
+
* 0차 실측: palm-kiosk-app/workflow/00-spike/00-spike-report.md
|
|
8
|
+
* 리뷰 반영(Codex, 2단계): schema 버전 필드·STATUS↔COMMIT 모순 검증·state 부재 명확화·Review Context·AJV(단계3)
|
|
9
|
+
*
|
|
10
|
+
* 단계 2(완료): 조립(handoff·Review Context·request·staged diff) + git 바인딩(staged tree OID) + dry-run 미리보기,
|
|
11
|
+
* 순수 도메인 검증 `validateVerdict`, `loadState`(부재 fail-closed).
|
|
12
|
+
* 단계 3A(현재): AJV 구조검증 `validateResponseStructure` + 승인 반영 `applyVerdict` + `processResponse`(fail-closed) + `writeState`(BOM 없음).
|
|
13
|
+
* codex 실제 호출과 분리(mockable) — 입력은 이미 존재하는 codex-response.json.
|
|
14
|
+
* 단계 3B(다음): codex exec/resume 실제 호출(thread_id 파싱) + --output-last-message 캡처 + processResponse 배선 +
|
|
15
|
+
* resume 후 `git status --porcelain` clean 검사 + machine_schema_version emit 재확인.
|
|
16
|
+
*
|
|
17
|
+
* 사용:
|
|
18
|
+
* pnpm req:review-codex <REQ-id> # workflow/REQ-<id>/ 대상
|
|
19
|
+
* pnpm req:review-codex --ticket <dir> # 임의 티켓 디렉터리
|
|
20
|
+
* 옵션: --handoff <path> (기본: ../palm-kiosk/docs/evaluation/project-memory/ai-handoff.md, 없으면 생략)
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'
|
|
23
|
+
import { resolve, join, relative } from 'node:path'
|
|
24
|
+
import { pathToFileURL } from 'node:url'
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import Ajv from 'ajv'
|
|
27
|
+
import { loadConfig, packageRoot, DEFAULTS, type ResolvedConfig } from './lib/config'
|
|
28
|
+
import {
|
|
29
|
+
createGitAdapter,
|
|
30
|
+
createCodexReviewerAdapter,
|
|
31
|
+
type GitAdapter,
|
|
32
|
+
type ReviewerAdapter,
|
|
33
|
+
} from './lib/adapters'
|
|
34
|
+
|
|
35
|
+
// codex JSONL thread 파싱은 어댑터 모듈 정본(re-export로 기존 import 호환).
|
|
36
|
+
export { parseThreadId } from './lib/adapters'
|
|
37
|
+
|
|
38
|
+
// git·codex(reviewer) 경계 = 어댑터(Phase 3, D-017-3/4). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — config 부재 시 현재 동작 보존).
|
|
39
|
+
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
40
|
+
// reviewer는 main에서 재할당 없음(기본 codex). 테스트 주입 이음새는 callReviewer 파라미터.
|
|
41
|
+
const reviewer: ReviewerAdapter = createCodexReviewerAdapter()
|
|
42
|
+
|
|
43
|
+
/** 구조화 응답 스키마 버전 (machine.schema.json과 동기). */
|
|
44
|
+
export const MACHINE_SCHEMA_VERSION = '1.1'
|
|
45
|
+
|
|
46
|
+
/** 리뷰 종류 (DEC-WF-027): design=설계문서 권위, phase=staged diff 권위. */
|
|
47
|
+
export type ReviewKind = 'design' | 'phase'
|
|
48
|
+
|
|
49
|
+
/** design 리뷰 권위 아티팩트 = 티켓 설계 문서 본문 3종. */
|
|
50
|
+
export interface DesignDocs {
|
|
51
|
+
requirement: string
|
|
52
|
+
design: string
|
|
53
|
+
plan: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type GitFn = (args: string[]) => string
|
|
57
|
+
|
|
58
|
+
// 모든 git 호출은 GitAdapter 경유(D-017-3). gitFn 주입점·plumbing 로직 불변.
|
|
59
|
+
const git: GitFn = (args) => gitAdapter.exec(args)
|
|
60
|
+
|
|
61
|
+
// ──────────────────────────────────────────────────────────── 조립 ──
|
|
62
|
+
|
|
63
|
+
export interface ReviewContext {
|
|
64
|
+
branch: string
|
|
65
|
+
reviewBaseSha: string
|
|
66
|
+
reviewTree: string
|
|
67
|
+
phase: string
|
|
68
|
+
previousResult: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ReviewPromptInput {
|
|
72
|
+
handoff?: string | null
|
|
73
|
+
reviewContext?: ReviewContext | null
|
|
74
|
+
reviewBaseSha: string
|
|
75
|
+
requestBody: string
|
|
76
|
+
reviewKind?: ReviewKind
|
|
77
|
+
stagedDiff?: string
|
|
78
|
+
designDocs?: DesignDocs | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 순수 함수: 리뷰 프롬프트 조립 (§9.5).
|
|
83
|
+
* 순서 = [handoff?] → [Review Context?] → REVIEW_BASE_SHA → REVIEW_KIND → codex-request 본문 → 권위 아티팩트.
|
|
84
|
+
* 권위 아티팩트: kind=phase → staged diff(현행), kind=design → 설계 문서 00/01/02 본문(DEC-WF-027 결정#3).
|
|
85
|
+
* handoff·reviewContext는 선택. 빈 request는 fail-closed로 거부. kind 기본값 phase(하위호환).
|
|
86
|
+
*/
|
|
87
|
+
export function assembleReviewPrompt(input: ReviewPromptInput): string {
|
|
88
|
+
const { handoff, reviewContext, reviewBaseSha, requestBody, stagedDiff, designDocs } = input
|
|
89
|
+
const kind: ReviewKind = input.reviewKind ?? 'phase'
|
|
90
|
+
if (!reviewBaseSha) throw new Error('reviewBaseSha 필요')
|
|
91
|
+
if (!requestBody || !requestBody.trim()) throw new Error('codex-request.md 본문이 비어 있음')
|
|
92
|
+
const blocks: string[] = []
|
|
93
|
+
if (handoff && handoff.trim()) blocks.push(handoff.trim())
|
|
94
|
+
if (reviewContext) {
|
|
95
|
+
blocks.push(
|
|
96
|
+
[
|
|
97
|
+
'# Review Context',
|
|
98
|
+
`- branch: ${reviewContext.branch}`,
|
|
99
|
+
`- review_base_sha: ${reviewContext.reviewBaseSha}`,
|
|
100
|
+
`- review_tree: ${reviewContext.reviewTree}`,
|
|
101
|
+
`- phase: ${reviewContext.phase}`,
|
|
102
|
+
`- previous_codex_result: ${reviewContext.previousResult}`,
|
|
103
|
+
].join('\n'),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
blocks.push(`---\nREVIEW_BASE_SHA: ${reviewBaseSha}`)
|
|
107
|
+
blocks.push(`---\nREVIEW_KIND: ${kind} (응답 review_kind가 동일해야 함)`)
|
|
108
|
+
blocks.push(`---\n${requestBody.trim()}`)
|
|
109
|
+
if (kind === 'design') {
|
|
110
|
+
if (!designDocs) throw new Error('design 리뷰 권위 아티팩트(00/01/02 designDocs) 필요')
|
|
111
|
+
blocks.push(
|
|
112
|
+
[
|
|
113
|
+
'---\n# 권위 아티팩트 = 설계 문서 00/01/02 (리뷰 대상 = 바인딩 대상)',
|
|
114
|
+
'## 00-requirement.md',
|
|
115
|
+
designDocs.requirement,
|
|
116
|
+
'## 01-design.md',
|
|
117
|
+
designDocs.design,
|
|
118
|
+
'## 02-plan.md',
|
|
119
|
+
designDocs.plan,
|
|
120
|
+
].join('\n'),
|
|
121
|
+
)
|
|
122
|
+
} else {
|
|
123
|
+
blocks.push(`---\n# 권위 아티팩트 = staged diff (리뷰 대상 = 바인딩 대상)\n${stagedDiff ?? ''}`)
|
|
124
|
+
}
|
|
125
|
+
return blocks.join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* git 바인딩 캡처 (§8.4): diff '텍스트'가 아니라 staged **tree OID**(git write-tree)를 바인딩.
|
|
130
|
+
* gitFn 주입 가능(테스트용).
|
|
131
|
+
*/
|
|
132
|
+
export function captureGitBinding(gitFn: GitFn = git): { reviewBaseSha: string; reviewTree: string } {
|
|
133
|
+
const reviewBaseSha = gitFn(['rev-parse', 'HEAD'])
|
|
134
|
+
const reviewTree = gitFn(['write-tree'])
|
|
135
|
+
return { reviewBaseSha, reviewTree }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 티켓 설계 문서 3종의 repo-relative 경로. shorthand 금지 — 각 경로를 티켓 디렉터리로 정규화. 파일명은 config(designDocs) 주입. */
|
|
139
|
+
export function designDocPaths(ticketRelDir: string, designDocs: DesignDocs): [string, string, string] {
|
|
140
|
+
const dir = ticketRelDir.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
141
|
+
return [`${dir}/${designDocs.requirement}`, `${dir}/${designDocs.design}`, `${dir}/${designDocs.plan}`]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* design 바인딩 캡처 (DEC-WF-027 결정#4): 티켓 00/01/02의 **세 full repo-relative 경로**를
|
|
146
|
+
* `git ls-files -s -- <3경로>`에 전달 → 출력 라인 정렬 → SHA256. (subset이라 write-tree 불가)
|
|
147
|
+
* **엔트리가 정확히 3개가 아니면 fail-closed**(git에 추적되지 않은 문서 = 미승인 취급). gitFn 주입 가능.
|
|
148
|
+
*/
|
|
149
|
+
export function captureDesignBinding(
|
|
150
|
+
ticketRelDir: string,
|
|
151
|
+
gitFn: GitFn = git,
|
|
152
|
+
designDocs: DesignDocs = DEFAULTS.designDocs,
|
|
153
|
+
): { designHash: string; paths: string[] } {
|
|
154
|
+
const paths = designDocPaths(ticketRelDir, designDocs)
|
|
155
|
+
const out = gitFn(['ls-files', '-s', '--', ...paths])
|
|
156
|
+
const lines = out
|
|
157
|
+
.split('\n')
|
|
158
|
+
.map((l) => l.trim())
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
if (lines.length !== 3)
|
|
161
|
+
throw new Error(
|
|
162
|
+
`design 바인딩 실패: 00/01/02 중 git 추적되지 않은 문서 존재(기대 3, 실제 ${lines.length}). 누락=미승인(fail-closed).`,
|
|
163
|
+
)
|
|
164
|
+
const designHash = createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
|
|
165
|
+
return { designHash, paths }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 설계 문서 3종 본문을 git **인덱스**에서 읽는다(`git show :<path>`) — Codex P2.
|
|
170
|
+
* 프롬프트 본문(리뷰 대상)과 design 바인딩 해시(`captureDesignBinding`, 인덱스 기반)가 **동일 대상**을 가리키게 하여
|
|
171
|
+
* "리뷰 대상 = 바인딩 대상"(결정#3)을 워킹트리 dirty 여부와 무관하게 보장한다.
|
|
172
|
+
* 인덱스에 없는 문서는 **어느 파일인지 명확한 에러**(fail-closed). gitFn 주입 가능.
|
|
173
|
+
*/
|
|
174
|
+
export function readDesignDocsFromIndex(
|
|
175
|
+
ticketRelDir: string,
|
|
176
|
+
gitFn: GitFn = git,
|
|
177
|
+
designDocs: DesignDocs = DEFAULTS.designDocs,
|
|
178
|
+
): DesignDocs {
|
|
179
|
+
const [reqP, designP, planP] = designDocPaths(ticketRelDir, designDocs)
|
|
180
|
+
const read = (p: string): string => {
|
|
181
|
+
try {
|
|
182
|
+
return gitFn(['show', `:${p}`])
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error(`--kind design 리뷰 불가: 설계 문서가 git 인덱스에 없음 — ${p} (req:new 스캐폴드 후 git add 필요)`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { requirement: read(reqP), design: read(designP), plan: read(planP) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─────────────────────────────────────────── 응답(verdict) 도메인 검증 ──
|
|
191
|
+
// 구조(필수 키·enum)는 단계 3에서 AJV(machine.schema.json)로 강제.
|
|
192
|
+
// 여기서는 AJV가 표현 못 하는 교차필드 모순·버전·git 바인딩을 fail-closed로 검사(§9.6 rule 2~3).
|
|
193
|
+
|
|
194
|
+
/** 리뷰 지적 항목 (machine.schema.json 1.1 findings[]). file은 nullable(전역 지적 등). */
|
|
195
|
+
export interface Finding {
|
|
196
|
+
severity?: string
|
|
197
|
+
detail?: string
|
|
198
|
+
file?: string | null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface Verdict {
|
|
202
|
+
machine_schema_version?: string
|
|
203
|
+
review_base_sha?: string
|
|
204
|
+
status?: string
|
|
205
|
+
commit_approved?: string
|
|
206
|
+
merge_ready?: string
|
|
207
|
+
risk_level?: string
|
|
208
|
+
review_kind?: string
|
|
209
|
+
findings?: Finding[]
|
|
210
|
+
next_action?: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const STATUS_VALUES = ['NEEDS_FIX', 'STEP_COMPLETE', 'COMPLETE']
|
|
214
|
+
const YESNO = ['yes', 'no']
|
|
215
|
+
const RISK_VALUES = ['LOW', 'HIGH']
|
|
216
|
+
const REVIEW_KIND_VALUES = ['design', 'phase']
|
|
217
|
+
|
|
218
|
+
export function validateVerdict(
|
|
219
|
+
v: Verdict,
|
|
220
|
+
opts: { schemaVersion?: string; reviewBaseSha?: string } = {},
|
|
221
|
+
): { ok: boolean; errors: string[] } {
|
|
222
|
+
const errors: string[] = []
|
|
223
|
+
const want = opts.schemaVersion ?? MACHINE_SCHEMA_VERSION
|
|
224
|
+
|
|
225
|
+
if (v.machine_schema_version !== want)
|
|
226
|
+
errors.push(`machine_schema_version 불일치(기대 ${want}, 실제 ${v.machine_schema_version})`)
|
|
227
|
+
if (!STATUS_VALUES.includes(v.status ?? '')) errors.push(`status 비유효: ${v.status}`)
|
|
228
|
+
if (!YESNO.includes(v.commit_approved ?? '')) errors.push(`commit_approved 비유효: ${v.commit_approved}`)
|
|
229
|
+
if (!YESNO.includes(v.merge_ready ?? '')) errors.push(`merge_ready 비유효: ${v.merge_ready}`)
|
|
230
|
+
if (!RISK_VALUES.includes(v.risk_level ?? '')) errors.push(`risk_level 비유효: ${v.risk_level}`)
|
|
231
|
+
|
|
232
|
+
if (!v.review_base_sha) errors.push('review_base_sha 누락')
|
|
233
|
+
else if (opts.reviewBaseSha && v.review_base_sha !== opts.reviewBaseSha)
|
|
234
|
+
errors.push(`review_base_sha 불일치(state ${opts.reviewBaseSha} ≠ resp ${v.review_base_sha})`)
|
|
235
|
+
|
|
236
|
+
// review_kind enum (1.1) — design|phase 만 허용
|
|
237
|
+
if (!REVIEW_KIND_VALUES.includes(v.review_kind ?? '')) errors.push(`review_kind 비유효: ${v.review_kind}`)
|
|
238
|
+
|
|
239
|
+
// D15 도메인(1.1): NEEDS_FIX면 findings·next_action이 actionable 해야 함.
|
|
240
|
+
// 타입 가드 필수 — 악성/파손 응답(next_action:1 등)이 .trim()에서 throw하면 fail-closed가 깨진다(Codex P2).
|
|
241
|
+
if (v.status === 'NEEDS_FIX') {
|
|
242
|
+
if (!Array.isArray(v.findings) || v.findings.length === 0)
|
|
243
|
+
errors.push('NEEDS_FIX인데 findings가 비어 있음(지적 1건 이상 필요)')
|
|
244
|
+
if (typeof v.next_action !== 'string' || !v.next_action.trim())
|
|
245
|
+
errors.push('NEEDS_FIX인데 next_action이 비어 있음')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 교차필드 모순 (§9.6 rule3) — STATUS와 COMMIT/MERGE를 함께 검증
|
|
249
|
+
if (v.commit_approved === 'yes' && v.status === 'NEEDS_FIX')
|
|
250
|
+
errors.push('모순: commit_approved=yes 인데 status=NEEDS_FIX')
|
|
251
|
+
if (v.merge_ready === 'yes' && v.status !== 'COMPLETE')
|
|
252
|
+
errors.push('모순: merge_ready=yes 인데 status≠COMPLETE')
|
|
253
|
+
if (v.merge_ready === 'yes' && v.commit_approved !== 'yes')
|
|
254
|
+
errors.push('모순: merge_ready=yes 인데 commit_approved≠yes')
|
|
255
|
+
|
|
256
|
+
return { ok: errors.length === 0, errors }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ───────────────────────────────────────────────────────── state.json ──
|
|
260
|
+
|
|
261
|
+
/** phases[] 항목(DEC-WF-027 phase 추적). 티켓 저자가 state.json/02-plan에 정의(req-new은 빈 배열로 초기화). */
|
|
262
|
+
export interface PhaseEntry {
|
|
263
|
+
id: string
|
|
264
|
+
approved: boolean
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 승인 증거 핀(REQ-016 A1, D-016-2). 승인 시 state.json에 기록되는 런타임 핀.
|
|
269
|
+
* 내구 audit은 커밋된 아카이브(D-016-1)/매니페스트(Phase B). kind 격리: phase=approved_tree, design=design_hash.
|
|
270
|
+
*/
|
|
271
|
+
export interface ApprovalEvidence {
|
|
272
|
+
response_path: string
|
|
273
|
+
response_sha256: string
|
|
274
|
+
review_kind: ReviewKind
|
|
275
|
+
phase_id: string | null
|
|
276
|
+
review_base_sha: string
|
|
277
|
+
approved_tree?: string
|
|
278
|
+
design_hash?: string | null
|
|
279
|
+
codex_thread_id: string
|
|
280
|
+
machine_schema_version: string
|
|
281
|
+
status: string
|
|
282
|
+
commit_approved: string
|
|
283
|
+
approved_at: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface WorkflowState {
|
|
287
|
+
id: string
|
|
288
|
+
phase: string
|
|
289
|
+
branch?: string
|
|
290
|
+
review_base_sha?: string | null
|
|
291
|
+
design_approved?: boolean
|
|
292
|
+
design_approved_hash?: string | null
|
|
293
|
+
current_phase?: string | null
|
|
294
|
+
phases?: PhaseEntry[]
|
|
295
|
+
// REQ-016 A1: 승인 증거 핀(kind 격리) + grandfathering 트리거. 반대 kind 증거는 미오염.
|
|
296
|
+
approval_evidence?: ApprovalEvidence
|
|
297
|
+
design_approval_evidence?: ApprovalEvidence
|
|
298
|
+
approval_evidence_required?: boolean
|
|
299
|
+
[k: string]: unknown
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** state.phases를 안전하게 PhaseEntry[]로 읽음(부재/비배열→[], id 문자열 항목만). */
|
|
303
|
+
export function readPhases(state: WorkflowState): PhaseEntry[] {
|
|
304
|
+
const raw = (state as { phases?: unknown }).phases
|
|
305
|
+
if (!Array.isArray(raw)) return []
|
|
306
|
+
return raw.filter(
|
|
307
|
+
(p): p is PhaseEntry => !!p && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* phase 리뷰 대상 해소(순수, Phase 4 — "엉뚱한 phase 승인" 차단).
|
|
313
|
+
* - kind=design → 대상 없음(phaseId=null).
|
|
314
|
+
* - kind=phase + phases[] 비어있음 → 레거시 하위호환(phase 추적 없이 기존 동작, phaseId=null).
|
|
315
|
+
* - kind=phase + phases[] 있음 + `--phase` 누락 → FAIL(대상 모호).
|
|
316
|
+
* - kind=phase + phases[] 있음 + id 불일치(exact) → FAIL(append 금지).
|
|
317
|
+
* - kind=phase + phases[] 있음 + id 일치 → phaseId 반환(이미 승인된 phase여도 허용=멱등).
|
|
318
|
+
*/
|
|
319
|
+
export function resolvePhaseTarget(
|
|
320
|
+
state: WorkflowState,
|
|
321
|
+
kind: ReviewKind,
|
|
322
|
+
phaseOpt: string | null,
|
|
323
|
+
): { ok: boolean; phaseId: string | null; error?: string } {
|
|
324
|
+
if (kind !== 'phase') return { ok: true, phaseId: null }
|
|
325
|
+
// 레거시 판정은 **raw 배열 길이**로 — readPhases(필터) 길이로 하면 malformed 비-빈 phases[]가 레거시로 강등되어
|
|
326
|
+
// --phase 없이 phase 승인되는 우회가 생긴다(Codex P2). 진짜 빈 배열/부재만 레거시 하위호환.
|
|
327
|
+
const raw = (state as { phases?: unknown }).phases
|
|
328
|
+
const rawLen = Array.isArray(raw) ? raw.length : 0
|
|
329
|
+
if (rawLen === 0) return { ok: true, phaseId: null } // 레거시(빈 배열/부재)
|
|
330
|
+
if (!phaseOpt) return { ok: false, phaseId: null, error: 'phases[]가 정의된 티켓은 --phase <id> 필수(대상 모호)' }
|
|
331
|
+
const ids = readPhases(state).map((p) => p.id)
|
|
332
|
+
if (!ids.includes(phaseOpt))
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
phaseId: null,
|
|
336
|
+
error: `--phase "${phaseOpt}" 가 state.phases[].id와 불일치(유효 id: ${ids.join(', ') || '(없음)'})`,
|
|
337
|
+
}
|
|
338
|
+
return { ok: true, phaseId: phaseOpt }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** state.json 로드. 부재/파손은 **명확한 에러**(자동 생성·애매한 fallback 금지 — Codex 리뷰 P1). */
|
|
342
|
+
export function loadState(ticketDir: string): WorkflowState {
|
|
343
|
+
const p = join(ticketDir, 'state.json')
|
|
344
|
+
if (!existsSync(p))
|
|
345
|
+
throw new Error(`state.json 없음: ${p}\n → req:new으로 티켓을 먼저 생성하세요(자동 생성하지 않음).`)
|
|
346
|
+
let raw: unknown
|
|
347
|
+
try {
|
|
348
|
+
raw = JSON.parse(readFileSync(p, 'utf8'))
|
|
349
|
+
} catch (e) {
|
|
350
|
+
throw new Error(`state.json 파싱 실패: ${p} — ${(e as Error).message}`)
|
|
351
|
+
}
|
|
352
|
+
const s = raw as WorkflowState
|
|
353
|
+
if (!s || typeof s !== 'object' || !s.id || !s.phase)
|
|
354
|
+
throw new Error(`state.json 필수 필드(id, phase) 누락: ${p}`)
|
|
355
|
+
return s
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function readPreviousResult(ticketDir: string): string {
|
|
359
|
+
const p = join(ticketDir, 'codex-response.json')
|
|
360
|
+
if (!existsSync(p)) return 'none'
|
|
361
|
+
try {
|
|
362
|
+
const v = JSON.parse(readFileSync(p, 'utf8')) as Verdict
|
|
363
|
+
return v.status ?? 'unknown'
|
|
364
|
+
} catch {
|
|
365
|
+
return 'unparseable'
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─────────────────────────── 응답 구조검증(AJV) + 상태 반영 (단계 3A) ──
|
|
370
|
+
// 구조(필수·enum·additionalProperties)는 AJV(machine.schema.json), 교차필드·바인딩은 validateVerdict.
|
|
371
|
+
// codex 실제 호출(단계 3B)과 분리 — 본 함수들은 이미 존재하는 codex-response.json을 입력으로 받음(mockable).
|
|
372
|
+
|
|
373
|
+
/** 정본 스키마 기본 경로(config 부재 시 fallback). 현재 동작 보존: packageRoot/workflow/machine.schema.json. */
|
|
374
|
+
export const MACHINE_SCHEMA_PATH = resolve(packageRoot(), 'workflow', 'machine.schema.json')
|
|
375
|
+
|
|
376
|
+
/** 구조 검증(AJV): 정본 스키마(필수·enum·additionalProperties)로 codex-response 형식 강제. schemaPath 주입 필수(config 배선). */
|
|
377
|
+
export function validateResponseStructure(
|
|
378
|
+
obj: unknown,
|
|
379
|
+
schemaPath: string,
|
|
380
|
+
): { ok: boolean; errors: string[] } {
|
|
381
|
+
const ajv = new Ajv({ allErrors: true })
|
|
382
|
+
const schema = JSON.parse(readFileSync(schemaPath, 'utf8')) as object
|
|
383
|
+
const validate = ajv.compile(schema)
|
|
384
|
+
const ok = validate(obj)
|
|
385
|
+
const errors = ok
|
|
386
|
+
? []
|
|
387
|
+
: (validate.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message ?? ''}`.trim())
|
|
388
|
+
return { ok, errors }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export interface Binding {
|
|
392
|
+
reviewBaseSha: string
|
|
393
|
+
reviewTree: string
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 승인 판정 반영(순수).
|
|
398
|
+
* **전제: `validateResponseStructure`(AJV) + `validateVerdict`(도메인) + expectedKind(요청 kind==응답 review_kind)를
|
|
399
|
+
* 이미 통과한 verdict에만 사용**한다(expectedKind 게이트는 `processResponse`가 담당 — 여기선 재검사 안 함).
|
|
400
|
+
* 검증을 건너뛰고 직접 호출하지 말 것 — 오용 시 fail-closed 보장이 깨진다. 정상 경로는 `processResponse`가 호출.
|
|
401
|
+
* 승인은 commit_approved=yes & status∈{STEP_COMPLETE,COMPLETE}일 때만.
|
|
402
|
+
* kind-aware(결정#8): kind=design → design_approved/_hash만, kind=phase → approved_diff_hash/commit_allowed만.
|
|
403
|
+
* 반대 kind 필드는 base에서 보존(한 kind 갱신이 다른 kind 승인을 미변경). 기본 kind=phase(하위호환).
|
|
404
|
+
*/
|
|
405
|
+
export function applyVerdict(args: {
|
|
406
|
+
base: WorkflowState
|
|
407
|
+
binding: Binding
|
|
408
|
+
verdict: Verdict
|
|
409
|
+
kind?: ReviewKind
|
|
410
|
+
designHash?: string | null
|
|
411
|
+
phaseId?: string | null
|
|
412
|
+
}): WorkflowState {
|
|
413
|
+
const { base, binding, verdict } = args
|
|
414
|
+
const kind: ReviewKind = args.kind ?? 'phase'
|
|
415
|
+
const approvable =
|
|
416
|
+
verdict.commit_approved === 'yes' &&
|
|
417
|
+
(verdict.status === 'STEP_COMPLETE' || verdict.status === 'COMPLETE')
|
|
418
|
+
|
|
419
|
+
if (kind === 'design') {
|
|
420
|
+
// design 승인은 non-null designHash(freshness anchor) 필수 — 누락 시 approved=true/hash=null 깨진 상태 금지(Codex P3, fail-closed).
|
|
421
|
+
const hashOk = typeof args.designHash === 'string' && args.designHash.trim().length > 0
|
|
422
|
+
return approvable && hashOk
|
|
423
|
+
? { ...base, design_approved: true, design_approved_hash: args.designHash }
|
|
424
|
+
: { ...base, design_approved: false, design_approved_hash: null }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const approvedState: WorkflowState = {
|
|
428
|
+
...base,
|
|
429
|
+
approved_diff_hash: approvable ? binding.reviewTree : null,
|
|
430
|
+
commit_allowed: approvable,
|
|
431
|
+
}
|
|
432
|
+
// Phase 4: 승인 + 추적 phase(phaseId)면 해당 phase만 approved=true·current_phase=id (자동 advance 없음).
|
|
433
|
+
// 미승인이거나 레거시(phaseId 없음)면 phases/current_phase 미변경.
|
|
434
|
+
// 원본 배열을 보존하며 **일치 항목만** 토글 — 계약 외(malformed) 항목도 그대로 유지(state 무손실, Codex P3).
|
|
435
|
+
if (approvable && typeof args.phaseId === 'string' && args.phaseId.length > 0) {
|
|
436
|
+
const phaseId = args.phaseId
|
|
437
|
+
const rawPhases = (base as { phases?: unknown }).phases
|
|
438
|
+
const phases = (Array.isArray(rawPhases) ? rawPhases : []).map((p) =>
|
|
439
|
+
p && typeof p === 'object' && (p as { id?: unknown }).id === phaseId ? { ...(p as object), approved: true } : p,
|
|
440
|
+
) as PhaseEntry[]
|
|
441
|
+
return { ...approvedState, phases, current_phase: phaseId }
|
|
442
|
+
}
|
|
443
|
+
return approvedState
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* codex-response.json을 검증하고 다음 state를 계산(파일 읽기만, 부수효과 없음).
|
|
448
|
+
* 검증 = AJV 구조 + validateVerdict 도메인 + **expectedKind**(응답 review_kind == 요청 kind, 결정#7 — 교차오염 차단).
|
|
449
|
+
* 실패 시 fail-closed(승인 미부여). kind 기본값 phase(하위호환).
|
|
450
|
+
* kind-aware 기록(결정#8):
|
|
451
|
+
* - phase: review_base_sha/review_diff_hash·codex_thread_id 항상 갱신, 승인 시 approved_diff_hash/commit_allowed.
|
|
452
|
+
* - design: codex_thread_id만 갱신(phase 바인딩 필드 미변경), 승인 시 design_approved/_hash. 실패 시 design 승인 무효(fail-closed).
|
|
453
|
+
*/
|
|
454
|
+
export function processResponse(args: {
|
|
455
|
+
ticketDir: string
|
|
456
|
+
state: WorkflowState
|
|
457
|
+
binding: Binding
|
|
458
|
+
threadId: string
|
|
459
|
+
kind?: ReviewKind
|
|
460
|
+
designHash?: string | null
|
|
461
|
+
phaseId?: string | null
|
|
462
|
+
designValid?: boolean
|
|
463
|
+
schemaPath?: string
|
|
464
|
+
// REQ-016 A1: 승인 시 증거 핀 정본(아카이브 경로+sha). 미제공 시 evidence 미부착(하위호환).
|
|
465
|
+
archive?: { path: string; sha256: string }
|
|
466
|
+
approvedAt?: string
|
|
467
|
+
}): { ok: boolean; errors: string[]; nextState: WorkflowState } {
|
|
468
|
+
const { ticketDir, state, binding, threadId, designHash, schemaPath } = args
|
|
469
|
+
const kind: ReviewKind = args.kind ?? 'phase'
|
|
470
|
+
const respPath = join(ticketDir, 'codex-response.json')
|
|
471
|
+
if (!existsSync(respPath)) throw new Error(`codex-response.json 없음: ${respPath}`)
|
|
472
|
+
let verdict: Verdict
|
|
473
|
+
try {
|
|
474
|
+
verdict = JSON.parse(readFileSync(respPath, 'utf8')) as Verdict
|
|
475
|
+
} catch (e) {
|
|
476
|
+
throw new Error(`codex-response.json 파싱 실패: ${respPath} — ${(e as Error).message}`)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const struct = validateResponseStructure(verdict, schemaPath ?? MACHINE_SCHEMA_PATH)
|
|
480
|
+
const domain = validateVerdict(verdict, { reviewBaseSha: binding.reviewBaseSha })
|
|
481
|
+
const kindMismatch = verdict.review_kind !== kind
|
|
482
|
+
// design 승인엔 non-null designHash 필수(freshness anchor) — 누락은 fail-closed(Codex P3).
|
|
483
|
+
const designHashMissing = kind === 'design' && !(typeof designHash === 'string' && designHash.trim().length > 0)
|
|
484
|
+
// Phase 4: 추적 phase(phaseId 있음)는 유효 design 승인 전제(D13 동일) 없으면 fail-closed(#6).
|
|
485
|
+
const tracked = kind === 'phase' && typeof args.phaseId === 'string' && args.phaseId.length > 0
|
|
486
|
+
const designBlocked = tracked && args.designValid !== true
|
|
487
|
+
const errors = [...struct.errors, ...domain.errors]
|
|
488
|
+
if (kindMismatch) errors.push(`review_kind 불일치(요청 ${kind} ≠ 응답 ${String(verdict.review_kind)})`)
|
|
489
|
+
if (designHashMissing) errors.push('design 리뷰인데 designHash(바인딩 해시) 누락 — 승인 불가(fail-closed)')
|
|
490
|
+
if (designBlocked) errors.push('phase 승인 전제: 유효 design 승인 필요(design_approved=true + freshness 일치)')
|
|
491
|
+
const ok = struct.ok && domain.ok && !kindMismatch && !designHashMissing && !designBlocked
|
|
492
|
+
|
|
493
|
+
if (kind === 'design') {
|
|
494
|
+
// A1-P2-1: 같은 kind(design)의 기존 증거는 항상 stale로 보고 제거(base에서 omit). 반대 kind(phase)는 보존.
|
|
495
|
+
const { design_approval_evidence: _staleDesignEv, ...stateRest } = state
|
|
496
|
+
const base: WorkflowState = { ...stateRest, codex_thread_id: threadId }
|
|
497
|
+
const nextState = ok
|
|
498
|
+
? applyVerdict({ base, binding, verdict, kind, designHash })
|
|
499
|
+
: { ...base, design_approved: false, design_approved_hash: null }
|
|
500
|
+
// A1(D-016-2): design 승인 + archive 제공 시에만 design_approval_evidence 재부착(fresh). 그 외엔 위 omit으로 미부착.
|
|
501
|
+
if (ok && nextState.design_approved === true && args.archive) {
|
|
502
|
+
nextState.design_approval_evidence = buildApprovalEvidence({
|
|
503
|
+
kind,
|
|
504
|
+
verdict,
|
|
505
|
+
binding,
|
|
506
|
+
phaseId: null,
|
|
507
|
+
designHash: designHash ?? null,
|
|
508
|
+
threadId,
|
|
509
|
+
archive: args.archive,
|
|
510
|
+
approvedAt: args.approvedAt ?? new Date().toISOString(),
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
return { ok, errors, nextState }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// A1-P2-1: 같은 kind(phase)의 기존 증거는 항상 stale로 보고 제거(base에서 omit). 반대 kind(design)는 보존.
|
|
517
|
+
const { approval_evidence: _stalePhaseEv, ...stateRest } = state
|
|
518
|
+
const base: WorkflowState = {
|
|
519
|
+
...stateRest,
|
|
520
|
+
codex_thread_id: threadId,
|
|
521
|
+
review_base_sha: binding.reviewBaseSha,
|
|
522
|
+
review_diff_hash: binding.reviewTree,
|
|
523
|
+
}
|
|
524
|
+
const nextState = ok
|
|
525
|
+
? applyVerdict({ base, binding, verdict, kind, phaseId: args.phaseId })
|
|
526
|
+
: { ...base, approved_diff_hash: null, commit_allowed: false }
|
|
527
|
+
|
|
528
|
+
// A1(D-016-2): phase 승인 + archive 제공 시에만 approval_evidence 재부착(fresh). 그 외엔 위 omit으로 미부착.
|
|
529
|
+
if (ok && nextState.commit_allowed === true && args.archive) {
|
|
530
|
+
nextState.approval_evidence = buildApprovalEvidence({
|
|
531
|
+
kind,
|
|
532
|
+
verdict,
|
|
533
|
+
binding,
|
|
534
|
+
phaseId: args.phaseId ?? null,
|
|
535
|
+
designHash: null,
|
|
536
|
+
threadId,
|
|
537
|
+
archive: args.archive,
|
|
538
|
+
approvedAt: args.approvedAt ?? new Date().toISOString(),
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { ok, errors, nextState }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** state.json 기록 — UTF-8(BOM 없음), 2-space + 끝 개행. */
|
|
546
|
+
export function writeState(ticketDir: string, state: WorkflowState): void {
|
|
547
|
+
writeFileSync(join(ticketDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// parseThreadId는 lib/adapters로 이동(codex CLI 전용 로직) — 상단에서 re-export.
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 허용 스크래치(현재 티켓의 **정확한 repo-relative 경로**) 제외, worktree dirty(Y≠' ') 또는 untracked(X='?')인
|
|
554
|
+
* `git status --porcelain` 라인 반환. status-line "diff"가 아닌 **절대 검사** — 호출 전부터 dirty였던 파일의
|
|
555
|
+
* 추가 수정도 감지(S2 보강). allowedScratch는 basename/substring이 아닌 **exact path** 매칭(Codex P1: D10 hard gate —
|
|
556
|
+
* `src/codex-response.json.ts`·다른 티켓·`.bak` 변형 오인 방지). 비어 있어야 "리뷰용 클린".
|
|
557
|
+
*/
|
|
558
|
+
export function findUnstagedOrUntracked(
|
|
559
|
+
statusLines: string[],
|
|
560
|
+
allowedScratch: string[],
|
|
561
|
+
ticketRel?: string,
|
|
562
|
+
): string[] {
|
|
563
|
+
const allowed = new Set(allowedScratch.map((p) => p.replace(/\\/g, '/')))
|
|
564
|
+
const respPrefix = ticketRel
|
|
565
|
+
? `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
|
|
566
|
+
: null
|
|
567
|
+
return statusLines.filter((l) => {
|
|
568
|
+
if (l.length < 3) return false
|
|
569
|
+
const x = l[0]
|
|
570
|
+
const y = l[1]
|
|
571
|
+
const body = l.slice(3).replace(/\\/g, '/')
|
|
572
|
+
// rename/copy(`R`/`C`)는 "old -> new" — src·dest 둘 다 검사(A2-P2-1: dest로 responses/ 주입 우회 차단).
|
|
573
|
+
const arrow = body.indexOf(' -> ')
|
|
574
|
+
if (arrow < 0 && allowed.has(body)) return false
|
|
575
|
+
const paths = arrow >= 0 ? [body.slice(0, arrow), body.slice(arrow + 4)] : [body]
|
|
576
|
+
// REQ-016 A1/D-016-4: 현재 티켓 responses/ 하위(src 또는 dest)는 **untracked 단일 아카이브만** 스크래치 허용.
|
|
577
|
+
// approvals.jsonl·tracked 수정/삭제/리네임/카피는 무조건 flag(커밋된 증거 변조/주입 차단).
|
|
578
|
+
if (respPrefix && paths.some((p) => p.startsWith(respPrefix))) return !isAllowedResponsesScratch(l, ticketRel as string)
|
|
579
|
+
return x === '?' || y !== ' '
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ───────────────────────────────── 승인 증거 아카이브/스크래치 (REQ-016 A1) ──
|
|
584
|
+
|
|
585
|
+
function escapeRegExp(s: string): string {
|
|
586
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** 아카이브 파일명 패턴: `<base>-rNN-(approved|needs-fix).json`(NN≥2자리). approvals.jsonl 등은 불일치. */
|
|
590
|
+
const ARCHIVE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]*-r\d{2,}-(approved|needs-fix)\.json$/
|
|
591
|
+
export function isArchiveFileName(name: string): boolean {
|
|
592
|
+
return ARCHIVE_NAME_RE.test(name)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** 아카이브 base(round namespace): design은 'design'(phaseId 무시), phase는 phaseId(없으면 'phase'=레거시). */
|
|
596
|
+
export function archiveBaseName(kind: ReviewKind, phaseId: string | null): string {
|
|
597
|
+
return kind === 'design' ? 'design' : phaseId && phaseId.length > 0 ? phaseId : 'phase'
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 아카이브 파일명 — r 2자리 zero-pad. */
|
|
601
|
+
export function archiveFileName(base: string, round: number, status: 'approved' | 'needs-fix'): string {
|
|
602
|
+
return `${base}-r${String(round).padStart(2, '0')}-${status}.json`
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 다음 round(deterministic): base의 기존 아카이브(approved·needs-fix 공유) max round + 1. design/phase base 분리. */
|
|
606
|
+
export function nextArchiveRound(existingNames: string[], base: string): number {
|
|
607
|
+
const re = new RegExp(`^${escapeRegExp(base)}-r(\\d{2,})-(approved|needs-fix)\\.json$`)
|
|
608
|
+
let max = 0
|
|
609
|
+
for (const n of existingNames) {
|
|
610
|
+
const m = re.exec(n)
|
|
611
|
+
if (!m) continue
|
|
612
|
+
const r = Number.parseInt(m[1] ?? '', 10)
|
|
613
|
+
if (Number.isFinite(r) && r > max) max = r
|
|
614
|
+
}
|
|
615
|
+
return max + 1
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* 스크래치 허용 판정(D-016-4): **현재 티켓 `responses/` 직계 + untracked(`??`) + 아카이브 파일명 패턴**만 true.
|
|
620
|
+
* `approvals.jsonl`·tracked(수정/삭제/리네임)·다른 티켓·중첩경로는 false(=리뷰/doctor에서 flag).
|
|
621
|
+
*/
|
|
622
|
+
export function isAllowedResponsesScratch(statusLine: string, ticketRel: string): boolean {
|
|
623
|
+
if (statusLine.length < 3) return false
|
|
624
|
+
const x = statusLine[0]
|
|
625
|
+
const y = statusLine[1]
|
|
626
|
+
if (x !== '?' || y !== '?') return false // untracked만
|
|
627
|
+
const path = statusLine.slice(3).replace(/\\/g, '/')
|
|
628
|
+
const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
|
|
629
|
+
if (!path.startsWith(prefix)) return false
|
|
630
|
+
const name = path.slice(prefix.length)
|
|
631
|
+
if (name.includes('/')) return false // 직계만
|
|
632
|
+
return isArchiveFileName(name)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 승인 증거 객체 생성(순수, D-016-2). 정본=아카이브 파일(`archive.path`/`sha256`).
|
|
637
|
+
* kind 격리: phase→approved_tree(=reviewTree), design→design_hash(=designApprovedHash). approvedAt은 주입(테스트 deterministic).
|
|
638
|
+
*/
|
|
639
|
+
export function buildApprovalEvidence(args: {
|
|
640
|
+
kind: ReviewKind
|
|
641
|
+
verdict: Verdict
|
|
642
|
+
binding: Binding
|
|
643
|
+
phaseId: string | null
|
|
644
|
+
designHash: string | null
|
|
645
|
+
threadId: string
|
|
646
|
+
archive: { path: string; sha256: string }
|
|
647
|
+
approvedAt: string
|
|
648
|
+
}): ApprovalEvidence {
|
|
649
|
+
const { kind, verdict, binding, phaseId, designHash, threadId, archive, approvedAt } = args
|
|
650
|
+
const ev: ApprovalEvidence = {
|
|
651
|
+
response_path: archive.path,
|
|
652
|
+
response_sha256: archive.sha256,
|
|
653
|
+
review_kind: kind,
|
|
654
|
+
phase_id: kind === 'phase' ? phaseId ?? null : null,
|
|
655
|
+
review_base_sha: binding.reviewBaseSha,
|
|
656
|
+
codex_thread_id: threadId,
|
|
657
|
+
machine_schema_version: verdict.machine_schema_version ?? '',
|
|
658
|
+
status: verdict.status ?? '',
|
|
659
|
+
commit_approved: verdict.commit_approved ?? '',
|
|
660
|
+
approved_at: approvedAt,
|
|
661
|
+
}
|
|
662
|
+
return kind === 'design' ? { ...ev, design_hash: designHash ?? null } : { ...ev, approved_tree: binding.reviewTree }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A2-P2-2: **검증된 processResponse 결과**로 아카이브 suffix 결정(순수).
|
|
667
|
+
* result.ok=false(무효·kind 불일치·모순) → null(아카이브 미생성 — round/finalize 오염 방지).
|
|
668
|
+
* 유효 + 승인(phase=commit_allowed·design=design_approved) → 'approved', 그 외(유효 NEEDS_FIX) → 'needs-fix'.
|
|
669
|
+
*/
|
|
670
|
+
export function archiveDecision(
|
|
671
|
+
result: { ok: boolean; nextState: WorkflowState },
|
|
672
|
+
kind: ReviewKind,
|
|
673
|
+
): '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'
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ──────────────────────────────────────────────────────────────── CLI ──
|
|
681
|
+
|
|
682
|
+
export interface Opts {
|
|
683
|
+
ticket: string | null
|
|
684
|
+
reqId: string | null
|
|
685
|
+
handoff: string | null
|
|
686
|
+
run: boolean
|
|
687
|
+
kind: ReviewKind
|
|
688
|
+
phase: string | null
|
|
689
|
+
root: string | null
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** CLI 파싱. `--kind design|phase`(기본 phase, 하위호환)·`--phase <id>`(phase kind 대상). 잘못된 --kind/--phase 값은 fail-closed throw. */
|
|
693
|
+
export function parseArgs(argv: string[]): Opts {
|
|
694
|
+
const opts: Opts = { ticket: null, reqId: null, handoff: null, run: false, kind: 'phase', phase: null, root: null }
|
|
695
|
+
for (let i = 0; i < argv.length; i++) {
|
|
696
|
+
const a = argv[i]
|
|
697
|
+
if (a === undefined) continue
|
|
698
|
+
if (a === '--ticket') opts.ticket = argv[++i] ?? null
|
|
699
|
+
else if (a === '--handoff') opts.handoff = argv[++i] ?? null
|
|
700
|
+
else if (a === '--root') {
|
|
701
|
+
const v = argv[++i]
|
|
702
|
+
if (v === undefined) throw new Error('--root 값 필요')
|
|
703
|
+
opts.root = v
|
|
704
|
+
}
|
|
705
|
+
else if (a === '--kind') {
|
|
706
|
+
const v = argv[++i]
|
|
707
|
+
if (v !== 'design' && v !== 'phase')
|
|
708
|
+
throw new Error(`--kind 값은 design 또는 phase여야 함 (받음: ${v ?? '(없음)'})`)
|
|
709
|
+
opts.kind = v
|
|
710
|
+
} else if (a === '--phase') {
|
|
711
|
+
const v = argv[++i]
|
|
712
|
+
if (v === undefined || v.startsWith('-')) throw new Error(`--phase <id> 값 필요 (받음: ${v ?? '(없음)'})`)
|
|
713
|
+
opts.phase = v
|
|
714
|
+
} else if (a === '--run') opts.run = true // 라이브 codex 호출(미지정 시 dry-run 미리보기)
|
|
715
|
+
else if (a === '--dry-run') opts.run = false
|
|
716
|
+
else if (!a.startsWith('-')) opts.reqId = a
|
|
717
|
+
}
|
|
718
|
+
return opts
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function resolveTicketDir(opts: Opts, cfg: ResolvedConfig): string {
|
|
722
|
+
if (opts.ticket) return resolve(opts.ticket)
|
|
723
|
+
if (opts.reqId) {
|
|
724
|
+
const id = opts.reqId.replace(/^REQ-/, '')
|
|
725
|
+
return join(cfg.workflowDirAbs, `REQ-${id}`)
|
|
726
|
+
}
|
|
727
|
+
throw new Error('REQ id 또는 --ticket <dir> 필요 (예: pnpm req:review-codex 2026-001)')
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function gitStatusLines(): string[] {
|
|
731
|
+
// core.quotePath=false: 비-ASCII 경로 octal 이스케이프 방지(exact 매칭 정합).
|
|
732
|
+
// --untracked-files=all: untracked 디렉터리 collapse(`?? responses/`) 방지 — responses/ 아카이브를 **개별 파일**로 봐야 스크래치 매처가 동작(A2-P2 후속).
|
|
733
|
+
return git(['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* 리뷰어 호출 + 응답 캡처(Phase 3 이음새). ReviewerAdapter.review로 codex(exec/resume)를 추상화하고
|
|
738
|
+
* lastMessage를 respPath에 기록(현행 `--output-last-message respPath`와 동일 효과 — respPath는 SCRATCH라 사후 무수정 검증 허용).
|
|
739
|
+
* thread_id 부재(exec에서 thread.started 없음)면 fail-closed throw. rv 주입 가능(default codex, 테스트=FakeReviewerAdapter).
|
|
740
|
+
*/
|
|
741
|
+
export function callReviewer(
|
|
742
|
+
rv: ReviewerAdapter,
|
|
743
|
+
opts: { prompt: string; schemaPath: string; resumeThreadId: string | null; cwd: string; respPath: string },
|
|
744
|
+
): { threadId: string } {
|
|
745
|
+
const { lastMessage, threadId } = rv.review({
|
|
746
|
+
prompt: opts.prompt,
|
|
747
|
+
schemaPath: opts.schemaPath,
|
|
748
|
+
resumeThreadId: opts.resumeThreadId,
|
|
749
|
+
cwd: opts.cwd,
|
|
750
|
+
})
|
|
751
|
+
if (!threadId) throw new Error('thread_id 파싱 실패 (codex exec --json에 thread.started 없음)')
|
|
752
|
+
writeFileSync(opts.respPath, lastMessage, 'utf8')
|
|
753
|
+
return { threadId }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function main(): void {
|
|
757
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
758
|
+
const cfg = loadConfig({ root: opts.root })
|
|
759
|
+
gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
|
|
760
|
+
const ticketDir = resolveTicketDir(opts, cfg)
|
|
761
|
+
const state = loadState(ticketDir) // 부재 시 명확한 에러
|
|
762
|
+
|
|
763
|
+
const requestPath = join(ticketDir, 'codex-request.md')
|
|
764
|
+
if (!existsSync(requestPath)) throw new Error(`codex-request.md 없음: ${requestPath}`)
|
|
765
|
+
const requestBody = readFileSync(requestPath, 'utf8')
|
|
766
|
+
|
|
767
|
+
// handoff: --handoff 우선, 없으면 cfg.handoffPathAbs(null=비활성 — 부재 시 생략, 현재 동작 보존).
|
|
768
|
+
const handoffPath = opts.handoff ? resolve(opts.handoff) : cfg.handoffPathAbs
|
|
769
|
+
const handoff = handoffPath && existsSync(handoffPath) ? readFileSync(handoffPath, 'utf8') : null
|
|
770
|
+
|
|
771
|
+
const { reviewBaseSha, reviewTree } = captureGitBinding()
|
|
772
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
773
|
+
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
774
|
+
|
|
775
|
+
// Phase 4: phase 리뷰 대상 해소(엉뚱한 phase 승인 차단). design/레거시(phases[] 빈)는 대상 없음(phaseId=null).
|
|
776
|
+
const phaseTarget = resolvePhaseTarget(state, opts.kind, opts.phase)
|
|
777
|
+
if (!phaseTarget.ok) throw new Error(`phase 대상 오류: ${phaseTarget.error}`)
|
|
778
|
+
const phaseId = phaseTarget.phaseId
|
|
779
|
+
|
|
780
|
+
// 추적 phase(phaseId)는 유효 design 승인(D13 동일: design_approved + freshness) 전제.
|
|
781
|
+
let designValid = false
|
|
782
|
+
if (phaseId) {
|
|
783
|
+
const approvedHash = typeof state.design_approved_hash === 'string' ? state.design_approved_hash : null
|
|
784
|
+
let currentHash: string | null = null
|
|
785
|
+
try {
|
|
786
|
+
currentHash = captureDesignBinding(ticketRel, git, cfg.designDocs).designHash
|
|
787
|
+
} catch {
|
|
788
|
+
currentHash = null
|
|
789
|
+
}
|
|
790
|
+
designValid = state.design_approved === true && approvedHash !== null && approvedHash === currentHash
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// kind별 권위 아티팩트: phase=staged diff, design=설계 문서 00/01/02 + design 바인딩 해시(결정#3·#4).
|
|
794
|
+
let stagedDiff: string | undefined
|
|
795
|
+
let designDocs: DesignDocs | undefined
|
|
796
|
+
let designHash: string | undefined
|
|
797
|
+
if (opts.kind === 'design') {
|
|
798
|
+
// 리뷰 본문·바인딩 해시 모두 git 인덱스에서 — "리뷰 대상 = 바인딩 대상"(결정#3, Codex P2). 누락 문서는 각 함수가 fail-closed.
|
|
799
|
+
designDocs = readDesignDocsFromIndex(ticketRel, git, cfg.designDocs)
|
|
800
|
+
designHash = captureDesignBinding(ticketRel, git, cfg.designDocs).designHash
|
|
801
|
+
} else {
|
|
802
|
+
stagedDiff = git(['diff', '--cached'])
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const reviewContext: ReviewContext = {
|
|
806
|
+
branch,
|
|
807
|
+
reviewBaseSha,
|
|
808
|
+
reviewTree,
|
|
809
|
+
phase: state.phase,
|
|
810
|
+
previousResult: readPreviousResult(ticketDir),
|
|
811
|
+
}
|
|
812
|
+
const prompt = assembleReviewPrompt({
|
|
813
|
+
handoff,
|
|
814
|
+
reviewContext,
|
|
815
|
+
reviewBaseSha,
|
|
816
|
+
requestBody,
|
|
817
|
+
reviewKind: opts.kind,
|
|
818
|
+
stagedDiff,
|
|
819
|
+
designDocs,
|
|
820
|
+
})
|
|
821
|
+
const previewPath = join(ticketDir, '.review-preview.txt')
|
|
822
|
+
writeFileSync(previewPath, prompt, 'utf8')
|
|
823
|
+
|
|
824
|
+
if (!opts.run) {
|
|
825
|
+
console.log('[req:review-codex] DRY-RUN (--run 지정 시 라이브 호출)')
|
|
826
|
+
console.log(
|
|
827
|
+
` ticket=${ticketDir} REQ=${state.id} phase=${state.phase} branch=${branch} kind=${opts.kind} phaseId=${phaseId ?? '(none)'}`,
|
|
828
|
+
)
|
|
829
|
+
console.log(
|
|
830
|
+
` review_base_sha=${reviewBaseSha} review_tree=${reviewTree}${designHash ? ` design_hash=${designHash}` : ''}${phaseId ? ` design_valid=${String(designValid)}` : ''}`,
|
|
831
|
+
)
|
|
832
|
+
console.log(` prompt ${prompt.length}자 → ${previewPath}`)
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── LIVE (단계 3B) ──
|
|
837
|
+
const respPath = join(ticketDir, 'codex-response.json')
|
|
838
|
+
const repoRel = (abs: string) => relative(cfg.root, abs).replace(/\\/g, '/')
|
|
839
|
+
// 워크플로 도구가 쓰는 메타데이터(응답·프리뷰·state)는 리뷰 대상 아님 → exact 경로로 허용(precondition/무수정검증 제외).
|
|
840
|
+
// 특히 state.json은 직전 라운드 writeState로 unstaged가 되므로 resume(2회차) precondition 통과에 필수(4C e2e 발견).
|
|
841
|
+
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
|
|
843
|
+
|
|
844
|
+
// Phase 4: 추적 phase는 유효 design 승인 전제(D13 동일) — 미충족 시 호출 전 fail-closed(불필요 codex 호출 방지).
|
|
845
|
+
if (phaseId && !designValid)
|
|
846
|
+
throw new Error(
|
|
847
|
+
'phase 리뷰 전 유효 design 승인 필요(design_approved=true + 현재 00/01/02 해시 일치) — 설계 재승인 후 진행하세요.',
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
// D10 precondition: 리뷰 전 워킹트리는 staged + 스크래치만 (사후 무수정 검증의 전제 — Codex P1).
|
|
851
|
+
// A2: ticketRel 전달 → 직전 라운드 untracked 아카이브(responses/)는 스크래치 허용, tracked 변조·approvals.jsonl은 flag.
|
|
852
|
+
const preDirty = findUnstagedOrUntracked(gitStatusLines(), SCRATCH, ticketRel)
|
|
853
|
+
if (preDirty.length)
|
|
854
|
+
throw new Error(
|
|
855
|
+
`리뷰 전 워킹트리에 unstaged/untracked 존재(D10) — 의도 변경은 git add, 그 외 정리 필요:\n ${preDirty.join('\n ')}`,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
console.warn(`⚠️ codex 실제 호출 (${isResume ? 'resume' : 'exec'}) — 호출 1회 발생 (DEC-WF-026: 호출 직전 확인)`)
|
|
859
|
+
|
|
860
|
+
// ReviewerAdapter 경유(Phase 3). exec/resume 분기·--output-last-message·thread 파싱은 어댑터가 담당.
|
|
861
|
+
// resumeThreadId 있으면 resume(thread 상속), 없으면 exec(--sandbox read-only) → thread.started 파싱.
|
|
862
|
+
const { threadId } = callReviewer(reviewer, {
|
|
863
|
+
prompt,
|
|
864
|
+
schemaPath: cfg.schemaPathAbs,
|
|
865
|
+
resumeThreadId: isResume ? (state.codex_thread_id as string) : null,
|
|
866
|
+
cwd: cfg.root,
|
|
867
|
+
respPath,
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
// 사후 리뷰어 무수정 검증: worktree 절대검사 + index(staged tree OID) 불변 (content 기반)
|
|
871
|
+
const postDirty = findUnstagedOrUntracked(gitStatusLines(), SCRATCH, ticketRel)
|
|
872
|
+
if (postDirty.length)
|
|
873
|
+
throw new Error(`리뷰 호출 후 워킹트리 변경(리뷰어 수정?):\n ${postDirty.join('\n ')}`)
|
|
874
|
+
const afterTree = git(['write-tree'])
|
|
875
|
+
if (afterTree !== reviewTree)
|
|
876
|
+
throw new Error(`리뷰 호출 후 staged tree 변경(리뷰어 index 수정?): ${reviewTree} → ${afterTree}`)
|
|
877
|
+
|
|
878
|
+
// A2(D-016-1 · A2-P2-2): reviewer 무수정 검증 이후, **검증 먼저(아카이브 없이)** → archiveDecision으로 suffix/생략 결정 → 기록.
|
|
879
|
+
// 무효(kind 불일치·모순 등)면 아카이브를 남기지 않는다(round/finalize 오염 방지). 유효 NEEDS_FIX는 보존, 승인은 evidence 핀 부착.
|
|
880
|
+
const approvedAt = new Date().toISOString()
|
|
881
|
+
const baseArgs = {
|
|
882
|
+
ticketDir,
|
|
883
|
+
state,
|
|
884
|
+
binding: { reviewBaseSha, reviewTree },
|
|
885
|
+
threadId,
|
|
886
|
+
kind: opts.kind,
|
|
887
|
+
designHash,
|
|
888
|
+
phaseId,
|
|
889
|
+
designValid,
|
|
890
|
+
schemaPath: cfg.schemaPathAbs,
|
|
891
|
+
}
|
|
892
|
+
const probe = processResponse(baseArgs) // 아카이브 없이 검증(진짜 승인 여부·유효성)
|
|
893
|
+
const decision = archiveDecision(probe, opts.kind) // null=아카이브 안 함(무효), 'approved'|'needs-fix'
|
|
894
|
+
let archiveDesc: { path: string; sha256: string } | undefined
|
|
895
|
+
if (decision) {
|
|
896
|
+
try {
|
|
897
|
+
const respBytes = readFileSync(respPath)
|
|
898
|
+
const base = archiveBaseName(opts.kind, phaseId)
|
|
899
|
+
const responsesDir = join(ticketDir, 'responses')
|
|
900
|
+
mkdirSync(responsesDir, { recursive: true })
|
|
901
|
+
const existing = readdirSync(responsesDir).filter((n) => isArchiveFileName(n))
|
|
902
|
+
const archiveAbs = join(responsesDir, archiveFileName(base, nextArchiveRound(existing, base), decision))
|
|
903
|
+
writeFileSync(archiveAbs, respBytes)
|
|
904
|
+
archiveDesc = { path: repoRel(archiveAbs), sha256: createHash('sha256').update(respBytes).digest('hex') }
|
|
905
|
+
} catch {
|
|
906
|
+
// 아카이브 기록 실패 — evidence 미부착(probe 결과 사용)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// 승인 + 아카이브가 있을 때만 evidence 핀 부착 위해 재호출, 아니면 검증된 probe 결과 사용.
|
|
910
|
+
const result =
|
|
911
|
+
decision === 'approved' && archiveDesc
|
|
912
|
+
? processResponse({ ...baseArgs, archive: archiveDesc, approvedAt })
|
|
913
|
+
: probe
|
|
914
|
+
writeState(ticketDir, result.nextState)
|
|
915
|
+
|
|
916
|
+
console.log(`[req:review-codex] ${result.ok ? 'OK' : 'FAIL(fail-closed)'} thread=${threadId}`)
|
|
917
|
+
console.log(
|
|
918
|
+
` commit_allowed=${String(result.nextState.commit_allowed)} approved=${String(result.nextState.approved_diff_hash ?? 'null')}`,
|
|
919
|
+
)
|
|
920
|
+
if (!result.ok) {
|
|
921
|
+
for (const e of result.errors) console.error(` - ${e}`)
|
|
922
|
+
process.exit(1)
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
927
|
+
if (isMain) main()
|