commitgate 0.9.5 → 0.9.8

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.
@@ -1,806 +1,623 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * req:commit — AI REQ 워크플로우 Phase B (REQ-2026-016). 승인된 phase를 커밋하는 래퍼.
4
- *
5
- * 설계 근거: 본 티켓 01-design.md D-016-3·3b·7·8·9.
6
- * 책임(전체): req:doctor 통과 게이트 → HIGH 사람확인 게이트 → source 커밋(승인 코드만) →
7
- * commit_allowed 소비 → evidence-finalize(approvals.jsonl 매니페스트 append + responses chore 커밋) → 2-커밋.
8
- * 복구/finalize 모드(pending_evidence_for)·design-finalize 포함.
9
- *
10
- * **B1: 순수 기반/매니페스트 모델**. **B2(현재): 정상 flow** — doctor 게이트→HIGH→source 커밋→evidence-finalize→소비(2-커밋).
11
- * 복구/finalize 모드(pending_evidence_for)·design-finalize는 **B3**. main()은 `--run` 없으면 dry-run(부작용 없음).
12
- * ⚠️ B2 도구 자체 커밋은 부트스트랩 수기(req:commit dogfood는 Phase C부터).
13
- */
14
- import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
15
- import { resolve, join, relative } from 'node:path'
16
- import { pathToFileURL } from 'node:url'
17
- import {
18
- loadState,
19
- writeState,
20
- readPhases,
21
- archiveBaseName,
22
- isArchiveFileName,
23
- isValidIsoInstant,
24
- type ApprovalEvidence,
25
- type ReviewKind,
26
- type WorkflowState,
27
- } from './review-codex'
28
- import { isConfinedArchivePath } from './req-doctor'
29
- import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type PackageManager, type ResolvedConfig } from './lib/config'
30
- import { createGitAdapter, safeSpawnSync, type GitAdapter } from './lib/adapters'
31
-
32
- // git=GitAdapter 경유(D-017-3), 패키지매니저=config. runDoctor(pnpm/npm 실행)는 cwd=gitRoot 필요(비-git 호출). main()이 loadConfig 후 config.root로 설정.
33
- let gitRoot = packageRoot()
34
- let pkgManager: PackageManager = DEFAULTS.packageManager
35
- let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
36
- const SHA256_RE = /^[0-9a-f]{64}$/i
37
- const GIT_OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i // git OID: 40(SHA-1) 또는 64(SHA-256)
38
- // ISO 검증은 review-codex의 isValidIsoInstant(형식+달력 유효성) 재사용 — 달력 불가능 값 거부(REQ-2026-030).
39
- // evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
40
- const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
41
- const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
42
- /** approvals.jsonl 엔트리 허용 top-level 키(이 외 = 주입/오염 → fail). */
43
- const MANIFEST_KEYS = new Set([
44
- 'kind',
45
- 'phase_id',
46
- 'response_path',
47
- 'response_sha256',
48
- 'review_base_sha',
49
- 'approved_tree',
50
- 'design_hash',
51
- 'approved_at',
52
- 'consumed_at',
53
- 'consumed_by_commit_sha',
54
- 'user_commit_confirmed',
55
- ])
56
-
57
- function escapeRegExp(s: string): string {
58
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
59
- }
60
-
61
- // ───────────────────────────────── approvals.jsonl 매니페스트 모델 (B1) ──
62
-
63
- /** HIGH 사람확인 감사 기록(암호학적 증명 아님 — D-016-8). */
64
- export interface UserCommitConfirmed {
65
- confirmed: boolean
66
- method: string
67
- confirmed_at: string
68
- note?: string
69
- }
70
-
71
- /** approvals.jsonl 한 줄(D-016-3b). kind 격리: phase=approved_tree, design=design_hash. */
72
- export interface ManifestEntry {
73
- kind: ReviewKind
74
- phase_id: string | null
75
- response_path: string
76
- response_sha256: string
77
- review_base_sha: string
78
- approved_tree?: string
79
- design_hash?: string
80
- approved_at: string
81
- consumed_at: string
82
- consumed_by_commit_sha: string
83
- user_commit_confirmed: UserCommitConfirmed | null
84
- }
85
-
86
- /**
87
- * 승인 증거(state pin)와 소비 정보로 매니페스트 엔트리 생성(순수, 고정 필드·키 순서).
88
- * kind 격리: phase→approved_tree만, design→design_hash만(반대 kind 필드 미포함).
89
- */
90
- export function buildManifestEntry(
91
- ev: ApprovalEvidence,
92
- consume: { consumedAt: string; consumedByCommitSha: string; userCommitConfirmed: UserCommitConfirmed | null },
93
- ): ManifestEntry {
94
- const base: ManifestEntry = {
95
- kind: ev.review_kind,
96
- phase_id: ev.phase_id ?? null,
97
- response_path: ev.response_path,
98
- response_sha256: ev.response_sha256,
99
- review_base_sha: ev.review_base_sha,
100
- approved_at: ev.approved_at,
101
- consumed_at: consume.consumedAt,
102
- consumed_by_commit_sha: consume.consumedByCommitSha,
103
- user_commit_confirmed: consume.userCommitConfirmed,
104
- }
105
- // fail-fast: kind별 필수 바인딩 필드(phase=approved_tree, design=design_hash). 반대 kind 필드는 미포함.
106
- if (ev.review_kind === 'design') {
107
- const designHash = ev.design_hash
108
- if (typeof designHash !== 'string' || !designHash)
109
- throw new Error('buildManifestEntry: design evidence에 design_hash 누락(fail-fast)')
110
- return { ...base, phase_id: null, design_hash: designHash }
111
- }
112
- const approvedTree = ev.approved_tree
113
- if (typeof approvedTree !== 'string' || !approvedTree)
114
- throw new Error('buildManifestEntry: phase evidence에 approved_tree 누락(fail-fast)')
115
- return { ...base, approved_tree: approvedTree }
116
- }
117
-
118
- /** 매니페스트 한 줄 직렬화(JSONL): JSON + 끝 개행. 고정 키 순서라 deterministic. */
119
- export function serializeManifestLine(entry: ManifestEntry): string {
120
- return `${JSON.stringify(entry)}\n`
121
- }
122
-
123
- /**
124
- * approvals.jsonl 내용 검증(순수, fail-closed). 문제 목록 반환(빈 배열=정상).
125
- * 검사: malformed JSONL · response_path confinement(현재 티켓 responses/ 직계) · SHA-256 형식 ·
126
- * phase kind의 phase_id 유효성 · (kind,phase_id,sha) 중복/주입.
127
- */
128
- export function validateManifest(
129
- content: string,
130
- opts: { ticketRel: string; validPhaseIds: string[] },
131
- ): string[] {
132
- const problems: string[] = []
133
- const seenKey = new Set<string>()
134
- const seenPath = new Set<string>()
135
- const lines = content.split('\n').map((l) => l.trim()).filter(Boolean)
136
- for (let i = 0; i < lines.length; i++) {
137
- const ln = i + 1
138
- let e: Record<string, unknown>
139
- try {
140
- e = JSON.parse(lines[i] as string) as Record<string, unknown>
141
- } catch {
142
- problems.push(`line ${ln}: malformed JSON`)
143
- continue
144
- }
145
- if (!e || typeof e !== 'object' || Array.isArray(e)) {
146
- problems.push(`line ${ln}: object 아님`)
147
- continue
148
- }
149
- // 예상 외 extra field 금지(주입 차단).
150
- for (const k of Object.keys(e)) if (!MANIFEST_KEYS.has(k)) problems.push(`line ${ln}: 예상 필드: ${k}`)
151
- const kind = e.kind
152
- if (kind !== 'phase' && kind !== 'design') problems.push(`line ${ln}: kind 비유효: ${String(kind)}`)
153
- // 공통: 경로 confinement, sha/OID/ISO 형식.
154
- const respPath = typeof e.response_path === 'string' ? e.response_path : ''
155
- if (!isConfinedArchivePath(respPath, opts.ticketRel)) problems.push(`line ${ln}: response_path 비confined: ${respPath}`)
156
- // B1-P2-1: manifest (=소비된 승인)의 response_path basename은 행의 kind/phase_id **승인본**(-approved)이어야.
157
- // (design→phase 아카이브·타 phase·needs-fix 가리키는 주입/변조 차단. expectedArchivePaths[chore 대상]는 needs-fix 포함 별개.)
158
- if (kind === 'phase' || kind === 'design') {
159
- const expBase = archiveBaseName(kind, kind === 'phase' && typeof e.phase_id === 'string' ? e.phase_id : null)
160
- const name = respPath.split('/').pop() ?? ''
161
- if (!new RegExp(`^${escapeRegExp(expBase)}-r\\d{2,}-approved\\.json$`).test(name))
162
- problems.push(`line ${ln}: response_path가 ${expBase}-rNN-approved.json 아님: ${name}`)
163
- }
164
- if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
165
- if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
166
- if (typeof e.consumed_by_commit_sha !== 'string' || !GIT_OID_RE.test(e.consumed_by_commit_sha)) problems.push(`line ${ln}: consumed_by_commit_sha 비-OID`)
167
- if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
168
- if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
169
- // kind별 strict 바인딩(반대 kind 필드 금지).
170
- if (kind === 'phase') {
171
- if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
172
- problems.push(`line ${ln}: phase_id 비유효: ${String(e.phase_id)}`)
173
- if (typeof e.approved_tree !== 'string' || !GIT_OID_RE.test(e.approved_tree)) problems.push(`line ${ln}: approved_tree 비-OID`)
174
- if ('design_hash' in e) problems.push(`line ${ln}: phase entry에 design_hash 금지`)
175
- } else if (kind === 'design') {
176
- if (e.phase_id !== null) problems.push(`line ${ln}: design entry는 phase_id=null이어야`)
177
- if (typeof e.design_hash !== 'string' || !SHA256_RE.test(e.design_hash)) problems.push(`line ${ln}: design_hash 비-64hex`)
178
- if ('approved_tree' in e) problems.push(`line ${ln}: design entry에 approved_tree 금지`)
179
- }
180
- // user_commit_confirmed: null 또는 유효 감사 기록(confirmed=true·method·ISO confirmed_at)만. (B2-block3)
181
- const ucc = e.user_commit_confirmed
182
- if (ucc !== null) {
183
- const p = userConfirmProblem(ucc)
184
- if (p) problems.push(`line ${ln}: user_commit_confirmed ${p}(null 또는 confirmed=true·method·ISO confirmed_at)`)
185
- }
186
- // 중복: (kind/phase/sha) + response_path 두 기준 모두.
187
- const key = `${String(kind)}:${String(e.phase_id)}:${String(e.response_sha256)}`
188
- if (seenKey.has(key)) problems.push(`line ${ln}: 중복 entry(kind/phase/sha): ${key}`)
189
- seenKey.add(key)
190
- if (respPath) {
191
- if (seenPath.has(respPath)) problems.push(`line ${ln}: 중복 response_path: ${respPath}`)
192
- seenPath.add(respPath)
193
- }
194
- }
195
- return problems
196
- }
197
-
198
- /**
199
- * 이번 target(kind/phaseId)의 **예상 응답 아카이브 repo-경로만** 반환(blanket `git add responses/` 금지).
200
- * archiveNames(responses/ 디렉터리 파일명)에서 해당 base의 rNN 아카이브만 필터 → 현재 티켓 responses/ 경로로.
201
- * needs-fix + approved 모두 포함(evidence chore는 실패 라운드까지 영속화) — 매니페스트 행 검증(approved만)과 별개.
202
- */
203
- export function expectedArchivePaths(
204
- archiveNames: string[],
205
- kind: ReviewKind,
206
- phaseId: string | null,
207
- ticketRel: string,
208
- ): string[] {
209
- const base = archiveBaseName(kind, phaseId)
210
- const re = new RegExp(`^${escapeRegExp(base)}-r(\\d{2,})-(approved|needs-fix)\\.json$`)
211
- const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
212
- // readdir 순서 비의존 — round(rNN) 오름차순 정렬(deterministic).
213
- return archiveNames
214
- .map((n) => ({ n, m: isArchiveFileName(n) ? re.exec(n) : null }))
215
- .filter((x): x is { n: string; m: RegExpExecArray } => x.m !== null)
216
- .sort((a, b) => Number.parseInt(a.m[1] ?? '0', 10) - Number.parseInt(b.m[1] ?? '0', 10))
217
- .map((x) => `${dir}/${x.n}`)
218
- }
219
-
220
- // ─────────────────────────────────────── B2: HIGH 게이트 / 소비 / preflight(순수) ──
221
-
222
- /**
223
- * user_commit_confirmed 감사 기록 형식 검증(순수). 유효하면 null, 아니면 사유.
224
- * 요구: confirmed===true · method(공백 아닌 문자열) · confirmed_at(ISO).
225
- * ⚠️ 위조불가 증명이 아니다 — Claude가 생성 가능한 플래그. 가장 강한 보장 = 사용자가 직접 `req:commit` 실행.
226
- */
227
- export function userConfirmProblem(ucc: unknown): string | null {
228
- if (!ucc || typeof ucc !== 'object') return '기록 없음'
229
- const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
230
- if (c.confirmed !== true) return 'confirmed=true 아님'
231
- if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
232
- if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
233
- return null
234
- }
235
-
236
- /**
237
- * HIGH 사람확인 게이트(D-016-8, 순수). HIGH인데 유효한 `user_commit_confirmed`(confirmed=true·method·ISO confirmed_at)가 없으면 차단.
238
- */
239
- export function userConfirmGate(state: WorkflowState): { blocked: boolean; reason?: string } {
240
- if (state.risk_level !== 'HIGH') return { blocked: false }
241
- const problem = userConfirmProblem(state.user_commit_confirmed)
242
- if (!problem) return { blocked: false }
243
- return {
244
- blocked: true,
245
- reason: `HIGH risk: user_commit_confirmed ${problem} req:commit 차단(감사 기록이며 위조불가 증명 아님; 가장 강한 보장=사용자가 직접 실행).`,
246
- }
247
- }
248
-
249
- /**
250
- * 승인 소비(D-016-9, 순수). **evidence 커밋 성공 후 마지막**에만 호출.
251
- * commit_allowed=false · approved_diff_hash=null · consumed_approvals[] append · user_commit_confirmed 초기화 · approval_evidence 핀 제거.
252
- */
253
- export function consumeState(state: WorkflowState, opts: { sourceCommitSha: string; consumedAt: string }): WorkflowState {
254
- const rawPrev = (state as { consumed_approvals?: unknown }).consumed_approvals
255
- const prev = Array.isArray(rawPrev) ? rawPrev : []
256
- const entry = {
257
- approved_tree: typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null,
258
- phase_id: typeof state.current_phase === 'string' ? state.current_phase : null,
259
- consumed_by_commit_sha: opts.sourceCommitSha,
260
- approval_consumed_at: opts.consumedAt,
261
- }
262
- // approval_evidence(현재 pending 승인 핀) + pending_evidence_for(복구 마커)는 소비와 함께 제거(다음 리뷰가 재부착).
263
- const { approval_evidence: _consumed, pending_evidence_for: _pending, ...rest } = state
264
- return {
265
- ...rest,
266
- commit_allowed: false,
267
- approved_diff_hash: null,
268
- consumed_approvals: [...prev, entry],
269
- user_commit_confirmed: null,
270
- }
271
- }
272
-
273
- // ─────────────────────────────────────────────── B3: 복구/finalize(순수) ──
274
-
275
- /**
276
- * 복구 마커 부착(순수, B3). **source 커밋 직후·evidence-finalize 전**에 기록 → 이후 중단 시 finalize로 복구.
277
- * approval_evidence는 그대로(소비 전), pending_evidence_for.source_commit_sha로 "source 커밋됨, evidence 미완"을 표시.
278
- */
279
- export function markPendingEvidence(state: WorkflowState, sourceCommitSha: string): WorkflowState {
280
- return { ...state, pending_evidence_for: { source_commit_sha: sourceCommitSha } }
281
- }
282
-
283
- /** state.pending_evidence_for.source_commit_sha 추출(순수). 없으면 null. */
284
- export function pendingSourceSha(state: WorkflowState): string | null {
285
- const pending = (state as { pending_evidence_for?: unknown }).pending_evidence_for
286
- if (!pending || typeof pending !== 'object') return null
287
- const sha = (pending as { source_commit_sha?: unknown }).source_commit_sha
288
- return typeof sha === 'string' && sha ? sha : null
289
- }
290
-
291
- /**
292
- * `req:commit --finalize` 적용 가능성 사전판정(순수, B3). source 미커밋 등 비-복구 상태에서 finalize 오용 차단.
293
- * ⚠️ B3-P1: HEAD가 아니라 **pending_evidence_for.source_commit_sha의 source 커밋 tree**를 approved와 대조.
294
- * (evidence 커밋 후엔 HEAD=evidence 커밋이라 HEAD^{tree}≠approved consume-only 복구창을 막아버리던 결함 수정.)
295
- * valid 조건: pending 마커 존재 · commit_allowed===true · approval_evidence 존재 · approved_diff_hash 문자열 · **sourceCommitTree == approved_diff_hash**.
296
- */
297
- export function recoveryClassify(state: WorkflowState, sourceCommitTree: string | null): { valid: boolean; reason: string } {
298
- if (!pendingSourceSha(state)) return { valid: false, reason: 'pending_evidence_for.source_commit_sha 없음 복구할 미완 작업 없음' }
299
- return recoveryCoreValid(state, sourceCommitTree)
300
- }
301
-
302
- /**
303
- * 복구 유효성 코어(순수, pending 마커 유무와 무관): commit_allowed·approval_evidence·approved_diff_hash·**sourceCommitTree == approved_diff_hash**.
304
- * recoveryClassify(마커 필수)와 resolveRecoverySource(orphan 복구) 공용으로 쓴다.
305
- */
306
- export function recoveryCoreValid(state: WorkflowState, sourceCommitTree: string | null): { valid: boolean; reason: string } {
307
- if (state.commit_allowed !== true) return { valid: false, reason: 'commit_allowed=true 아님 — 복구할 미완 승인 없음' }
308
- if (!state.approval_evidence) return { valid: false, reason: 'approval_evidence 없음' }
309
- const approved = typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null
310
- if (!approved) return { valid: false, reason: 'approved_diff_hash 없음' }
311
- if (sourceCommitTree !== approved)
312
- return { valid: false, reason: `source 커밋 tree(${String(sourceCommitTree)}) != approved(${approved}) — 잘못된 복구 대상` }
313
- return { valid: true, reason: 'finalize 유효: source 커밋 tree == approved, evidence/consume 복구 가능' }
314
- }
315
-
316
- /**
317
- * finalize 복구 대상 source SHA 해소(순수, P2-a marker 기록 crash 복구창).
318
- * pending 마커 있으면 SHA(viaOrphan=false).
319
- * ② 마커 없어도 HEAD가 승인 source(head.tree == approved_diff_hash + commit_allowed + approval_evidence)면 orphaned source로 HEAD 복구(viaOrphan=true).
320
- * ⚠️ 승인 tree 대조라 **승인 우회 아님** — source 커밋 성공 후 markPendingEvidence 전에 죽은 상태만 복구한다.
321
- */
322
- export function resolveRecoverySource(
323
- state: WorkflowState,
324
- head: { sha: string; tree: string } | null,
325
- ): { sourceSha: string | null; viaOrphan: boolean; reason: string } {
326
- const pending = pendingSourceSha(state)
327
- if (pending) return { sourceSha: pending, viaOrphan: false, reason: 'pending 마커' }
328
- if (!head) return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD 미상 — 복구할 미완 작업 없음' }
329
- const approved = typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null
330
- if (state.commit_allowed === true && !!state.approval_evidence && approved !== null && head.tree === approved)
331
- return { sourceSha: head.sha, viaOrphan: true, reason: 'orphaned source(HEAD tree == approved) 복구' }
332
- return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD가 승인 source 아님 — 복구할 미완 작업 없음' }
333
- }
334
-
335
- /**
336
- * approvals.jsonl에 **이 evidence가** 이미 finalize된 엔트리가 있는지(순수, 멱등 finalize용).
337
- * ⚠️ B3-R2: consumed_by_commit_sha만으로는 부족 같은 source SHA를 쓰는 design-finalize row 등에 오인될 수 있음.
338
- * sourceSha **+ evidence identity(kind·phase_id·response_sha256)** 전부 일치해야 동일 엔트리로 판정.
339
- */
340
- export function manifestHasConsumed(
341
- content: string,
342
- sourceSha: string,
343
- identity: { reviewKind: ReviewKind; phaseId: string | null; responseSha256: string },
344
- ): boolean {
345
- for (const line of content.split('\n').map((l) => l.trim()).filter(Boolean)) {
346
- try {
347
- const e = JSON.parse(line) as { consumed_by_commit_sha?: unknown; kind?: unknown; phase_id?: unknown; response_sha256?: unknown }
348
- if (
349
- e &&
350
- typeof e === 'object' &&
351
- e.consumed_by_commit_sha === sourceSha &&
352
- e.kind === identity.reviewKind &&
353
- (e.phase_id ?? null) === identity.phaseId &&
354
- e.response_sha256 === identity.responseSha256
355
- )
356
- return true
357
- } catch {
358
- // malformed 줄은 무시(무결성은 validateManifest 담당)
359
- }
360
- }
361
- return false
362
- }
363
-
364
- export interface PreflightInput {
365
- existingManifest: string // 현재 approvals.jsonl 내용('' = 없음)
366
- approvalEvidence: ApprovalEvidence | null
367
- archiveNames: string[] // readdir(responses).filter(isArchiveFileName)
368
- ticketRel: string
369
- validPhaseIds: string[]
370
- responsePathExists: boolean // existsSync(approval_evidence.response_path)
371
- userCommitConfirmed: unknown // state.user_commit_confirmed (후보 entry 구성용)
372
- placeholderCommitSha: string // 구조 사전검증용 valid OID(실제 sourceSha는 source 후)
373
- placeholderConsumedAt: string // 구조 사전검증용 valid ISO
374
- }
375
-
376
- /**
377
- * **source 커밋 전** evidence preflight(순수, B2-block1/2). 커밋 없이 잡을 수 있는 모든 evidence 실패를 먼저 수집.
378
- * 빈 배열 = 통과. 하나라도 있으면 git commit을 절대 실행하지 않는다(= source 후 실패 창 최소화).
379
- * 검사: (a) 기존 매니페스트 무결성 · (b) approval_evidence 존재/형식 · (c) expected 아카이브에 approved≥1 & 전부 confined ·
380
- * (d) response_path가 expected에 포함 · (e) response_path 파일 실제 존재 ·
381
- * (f) placeholder sourceSha로 후보 entry 빌드+전체 매니페스트 재검증(중복/구조 사전 차단).
382
- */
383
- export function evidencePreflight(inp: PreflightInput): string[] {
384
- const problems: string[] = []
385
- const opts = { ticketRel: inp.ticketRel, validPhaseIds: inp.validPhaseIds }
386
- // (a) 기존 approvals.jsonl 무결성
387
- if (inp.existingManifest.trim()) {
388
- const p = validateManifest(inp.existingManifest, opts)
389
- if (p.length) problems.push(`기존 approvals.jsonl 무결성 실패: ${p.join('; ')}`)
390
- }
391
- // (b) approval_evidence 존재/형식
392
- const ev = inp.approvalEvidence
393
- if (!ev) {
394
- problems.push('approval_evidence 없음')
395
- return problems
396
- }
397
- if (ev.review_kind !== 'phase' && ev.review_kind !== 'design')
398
- problems.push(`approval_evidence.review_kind 비유효: ${String(ev.review_kind)}`)
399
- if (typeof ev.response_path !== 'string' || !ev.response_path) {
400
- problems.push('approval_evidence.response_path 없음')
401
- return problems
402
- }
403
- // (c) expected 아카이브(target 한정)
404
- const expected = expectedArchivePaths(inp.archiveNames, ev.review_kind, ev.phase_id ?? null, inp.ticketRel)
405
- if (!expected.some((p) => /-r\d{2,}-approved\.json$/.test(p)))
406
- problems.push('expectedArchivePaths에 approved 아카이브 없음(needs-fix만 존재 가능)')
407
- for (const p of expected) if (!isConfinedArchivePath(p, inp.ticketRel)) problems.push(`archive 경로 비confined: ${p}`)
408
- // (d) response_path가 expected에 포함
409
- if (!expected.includes(ev.response_path)) problems.push(`approval_evidence.response_path가 expectedArchivePaths에 없음: ${ev.response_path}`)
410
- // (e) response_path 파일 실제 존재
411
- if (!inp.responsePathExists) problems.push(`approval_evidence.response_path 파일 부재: ${ev.response_path}`)
412
- // (f) placeholder sourceSha로 후보 entry 빌드 + 전체 매니페스트 재검증(source 후 실패 최소화)
413
- try {
414
- const candidate = buildManifestEntry(ev, {
415
- consumedAt: inp.placeholderConsumedAt,
416
- consumedByCommitSha: inp.placeholderCommitSha,
417
- userCommitConfirmed: (inp.userCommitConfirmed as UserCommitConfirmed | null) ?? null,
418
- })
419
- const p = validateManifest(inp.existingManifest + serializeManifestLine(candidate), opts)
420
- if (p.length) problems.push(`후보 manifest entry 검증 실패: ${p.join('; ')}`)
421
- } catch (e) {
422
- problems.push(`buildManifestEntry 실패: ${(e as Error).message}`)
423
- }
424
- return problems
425
- }
426
-
427
- // ─────────────────────────────────────────── CLI (B2: 정상 req:commit flow) ──
428
-
429
- export interface CommitArgs {
430
- ticket: string | null
431
- reqId: string | null
432
- run: boolean
433
- message: string | null
434
- messageFile: string | null // REQ-018: --message-file <path>(→ git commit -F). multi-line 메시지를 argv 거치지 않고 전달
435
- finalize: boolean // B3: source 재커밋 없이 evidence/consume만 복구
436
- finalizeDesign: boolean // B3: design 승인을 approvals.jsonl에 기록(source/consume 없음)
437
- root: string | null // config 탐색 루트(--root)
438
- }
439
-
440
- /** CLI 파싱(fail-closed). `--ticket`·`--run`·`--message/-m`·`--message-file`·`--finalize`·`--finalize-design`·`--root <dir>`. 값 누락·알 수 없는 옵션은 throw(메시지 상호배타/필수는 resolveMessageSource). */
441
- export function parseArgs(argv: string[]): CommitArgs {
442
- let ticket: string | null = null
443
- let reqId: string | null = null
444
- let run = false
445
- let message: string | null = null
446
- let messageFile: string | null = null
447
- let finalize = false
448
- let finalizeDesign = false
449
- let root: string | null = null
450
- for (let i = 0; i < argv.length; i++) {
451
- const a = argv[i]
452
- if (a === undefined) continue
453
- // bare `--`는 POSIX end-of-options 마커(DEC-011-3). ⚠️ 이후 인자도 계속 옵션으로 파싱해야 한다 —
454
- // 전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run으로 끝난다(가장 나쁜 실패).
455
- if (a === '--') continue
456
- else if (a === '--ticket') ticket = argv[++i] ?? null
457
- else if (a === '--run') run = true
458
- else if (a === '--message' || a === '-m') message = argv[++i] ?? null
459
- else if (a === '--message-file') {
460
- const v = argv[++i]
461
- if (v === undefined) throw new Error('--message-file 필요')
462
- messageFile = v
463
- } else if (a === '--finalize') finalize = true
464
- else if (a === '--finalize-design') finalizeDesign = true
465
- else if (a === '--root') {
466
- const v = argv[++i]
467
- if (v === undefined) throw new Error('--root 값 필요')
468
- root = v
469
- } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
470
- else reqId = a
471
- }
472
- if (finalize && finalizeDesign) throw new Error('--finalize 와 --finalize-design 동시 사용 불가')
473
- if (!ticket && !reqId) throw new Error('REQ id 또는 --ticket <dir> 필요')
474
- return { ticket, reqId, run, message, messageFile, finalize, finalizeDesign, root }
475
- }
476
-
477
- /**
478
- * 커밋 메시지 출처 해소(순수, REQ-018 D-018-3). **정상 source-커밋 flow 직전에만** 호출.
479
- * env fallback(CLI 다 없을 때만 REQ_COMMIT_MESSAGE_FILE) → 상호배타·필수 → **절대경로 정규화** → 존재검증.
480
- * ⚠️ messageFile은 `resolve()`로 절대경로화: existsFn(=existsSync)은 process.cwd 기준, git `-F`는 cwd=gitRoot라
481
- * 상대경로면 검증 위치와 git 읽기 위치가 어긋난다(CLI/env 동일 처리). existsFn 주입(테스트=fake).
482
- */
483
- export function resolveMessageSource(
484
- opts: { message: string | null; messageFile: string | null },
485
- env: string | undefined,
486
- existsFn: (p: string) => boolean,
487
- ): { message: string | null; messageFile: string | null } {
488
- const { message } = opts
489
- let messageFile = opts.messageFile
490
- if (message === null && messageFile === null && env !== undefined && env !== '') messageFile = env // env fallback(CLI 우선)
491
- if (message !== null && messageFile !== null)
492
- throw new Error('-m/--message --message-file/REQ_COMMIT_MESSAGE_FILE 동시 지정 불가')
493
- if (message === null && messageFile === null)
494
- throw new Error('커밋 메시지 필요 — -m <msg> 또는 --message-file <path>(또는 REQ_COMMIT_MESSAGE_FILE)')
495
- if (messageFile !== null) {
496
- const abs = resolve(messageFile) // 절대경로 정규화(existsFn↔git cwd 위치 일관)
497
- if (!existsFn(abs)) throw new Error(`--message-file 경로 없음: ${abs}`)
498
- messageFile = abs
499
- }
500
- return { message, messageFile }
501
- }
502
-
503
- /**
504
- * source 커밋 git args 빌더(순수, REQ-018 D-018-3). messageFile→`-F`(메시지 내용이 argv에 없음), message→`-m`.
505
- * 둘 다/둘 다 아님 → throw(방어 — 정상 경로는 resolveMessageSource가 보장).
506
- */
507
- export function buildCommitArgs(opts: { message: string | null; messageFile: string | null }): string[] {
508
- if (opts.message !== null && opts.messageFile !== null)
509
- throw new Error('buildCommitArgs: message·messageFile 동시 불가(방어)')
510
- if (opts.messageFile !== null) return ['commit', '-F', opts.messageFile]
511
- if (opts.message !== null) return ['commit', '-m', opts.message]
512
- throw new Error('buildCommitArgs: message 또는 messageFile 필요')
513
- }
514
-
515
- /**
516
- * 티켓 디렉터리 + req:doctor 인자 해소(순수, config.workflowDirAbs 기준).
517
- * doctorArgs에 **--root cfg.root 전파** 자식 req:doctor가 부모와 동일 root를 쓰도록(일관).
518
- */
519
- export function resolveCommitTarget(opts: CommitArgs, cfg: ResolvedConfig): { ticketDir: string; doctorArgs: string[] } {
520
- const rootArgs = ['--root', cfg.root]
521
- if (opts.ticket) {
522
- const ticketDir = resolve(opts.ticket)
523
- return { ticketDir, doctorArgs: ['--ticket', ticketDir, ...rootArgs] }
524
- }
525
- const norm = (opts.reqId as string).replace(/^REQ-/, '')
526
- return { ticketDir: join(cfg.workflowDirAbs, `REQ-${norm}`), doctorArgs: [norm, ...rootArgs] }
527
- }
528
-
529
- /** git 실행(GitAdapter 경유, config.root 기준). 실패 시 throw(fail-closed). */
530
- function git(args: string[]): string {
531
- return gitAdapter.exec(args)
532
- }
533
-
534
- /** req:doctor 게이트 — 별도 프로세스로 실행, exit≠0이면 throw(통과 못 하면 커밋 진입 불가). 패키지매니저별 argv는 buildScriptInvocation(npm은 `run --`). */
535
- function runDoctor(doctorArgs: string[]): void {
536
- const [cmd, ...rest] = buildScriptInvocation(pkgManager, 'req:doctor', doctorArgs)
537
- if (!cmd) throw new Error('buildScriptInvocation: 호출(패키지매니저 설정 오류)')
538
- // shell 없이 안전 실행(P1): pkg manager는 Windows에서 .cmd라 과거 shell:true였고 doctorArgs(reqId·root 경로)의 메타문자로 주입 가능했음.
539
- safeSpawnSync(cmd, rest, { cwd: gitRoot, stdio: 'inherit' })
540
- }
541
-
542
- /** `git diff --cached --name-only`를 정규화 경로 배열로. */
543
- function stagedNames(): string[] {
544
- return git(['diff', '--cached', '--name-only'])
545
- .split('\n')
546
- .map((p) => p.trim().replace(/\\/g, '/'))
547
- .filter(Boolean)
548
- }
549
-
550
- interface FinalizeCtx {
551
- ticketDir: string
552
- ticketRel: string
553
- responsesDir: string
554
- manifestPath: string
555
- state: WorkflowState
556
- ev: ApprovalEvidence
557
- existing: string
558
- archiveNames: string[]
559
- validPhaseIds: string[]
560
- sourceSha: string
561
- }
562
-
563
- /**
564
- * evidence-finalize(**멱등**) + 소비 — 정상 flow와 `--finalize` 복구가 공유.
565
- * 이미 sourceSha가 매니페스트에 있으면(=evidence 커밋은 됐고 consume만 못 함) append/chore를 skip하고 소비만 수행.
566
- * 소비는 항상 마지막. state.json은 scratch 유지(커밋 안 함).
567
- */
568
- function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
569
- // B3-R2: skip(소비-only) 경로 포함 — 변조된 매니페스트 위에서 consume 금지. 기존 무결성 먼저(복구 모드엔 preflight가 없으므로 필수).
570
- if (ctx.existing.trim()) {
571
- const ep = validateManifest(ctx.existing, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
572
- if (ep.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${ep.join('; ')}`)
573
- }
574
- const already = manifestHasConsumed(ctx.existing, ctx.sourceSha, {
575
- reviewKind: ctx.ev.review_kind,
576
- phaseId: ctx.ev.phase_id ?? null,
577
- responseSha256: ctx.ev.response_sha256,
578
- })
579
- if (!already) {
580
- const entry = buildManifestEntry(ctx.ev, {
581
- consumedAt: new Date().toISOString(),
582
- consumedByCommitSha: ctx.sourceSha,
583
- userCommitConfirmed: (ctx.state.user_commit_confirmed as UserCommitConfirmed | null) ?? null,
584
- })
585
- const newContent = ctx.existing + serializeManifestLine(entry)
586
- const reproblems = validateManifest(newContent, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
587
- if (reproblems.length)
588
- throw new Error(`(예상외) 매니페스트 검증 실패: ${reproblems.join('; ')} source=${ctx.sourceSha} 커밋됨, --finalize로 복구`)
589
- mkdirSync(ctx.responsesDir, { recursive: true })
590
- writeFileSync(ctx.manifestPath, newContent, 'utf8')
591
- const archivePaths = expectedArchivePaths(ctx.archiveNames, ctx.ev.review_kind, ctx.ev.phase_id ?? null, ctx.ticketRel)
592
- git(['add', ...archivePaths, `${ctx.ticketRel}/responses/approvals.jsonl`])
593
- const choreLeak = stagedNames().filter((p) => !p.startsWith(`${ctx.ticketRel}/responses/`))
594
- if (choreLeak.length) throw new Error(`evidence 커밋에 responses 외 staged 금지(코드/state 누수): ${choreLeak.join(', ')}`)
595
- git(['commit', '-m', `chore(${ctx.state.id}): evidence-finalize — ${ctx.ev.review_kind} ${ctx.ev.phase_id ?? ''} 아카이브·approvals.jsonl`])
596
- } else {
597
- console.log('[req:commit] evidence 이미 finalize됨(멱등 skip) — 소비만 수행')
598
- }
599
- // 소비(마지막) — commit_allowed=false·approved_diff_hash=null·pending 마커 제거.
600
- writeState(ctx.ticketDir, consumeState(ctx.state, { sourceCommitSha: ctx.sourceSha, consumedAt: new Date().toISOString() }))
601
- }
602
-
603
- /**
604
- * design-finalize(B3) — design 승인을 approvals.jsonl에 audit 기록(source 커밋·commit_allowed 소비 없음).
605
- * 멱등: 동일 design 엔트리(kind/sha 중복)면 skip. doctor는 정상 실행(우회 아님).
606
- */
607
- function designFinalize(args: {
608
- ticketDir: string
609
- ticketRel: string
610
- responsesDir: string
611
- manifestPath: string
612
- doctorArgs: string[]
613
- state: WorkflowState
614
- validPhaseIds: string[]
615
- }): void {
616
- const dev = (args.state.design_approval_evidence as ApprovalEvidence | undefined) ?? null
617
- if (!dev) throw new Error('design_approval_evidence 없음 design 승인 후 실행')
618
- if (dev.review_kind !== 'design') throw new Error(`design_approval_evidence.review_kind != design: ${String(dev.review_kind)}`)
619
- runDoctor(args.doctorArgs) // design-finalize도 doctor 우회 금지(정상)
620
- const opts = { ticketRel: args.ticketRel, validPhaseIds: args.validPhaseIds }
621
- const existing = existsSync(args.manifestPath) ? readFileSync(args.manifestPath, 'utf8') : ''
622
- // B3-P2: 기존 매니페스트 단독 무결성 먼저(중복+다른 오염 시 묵인 금지 — 오염이면 여기서 fail-closed).
623
- if (existing.trim()) {
624
- const existingProblems = validateManifest(existing, opts)
625
- if (existingProblems.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${existingProblems.join('; ')}`)
626
- }
627
- const headSha = git(['rev-parse', 'HEAD'])
628
- const entry = buildManifestEntry(dev, { consumedAt: new Date().toISOString(), consumedByCommitSha: headSha, userCommitConfirmed: null })
629
- const newContent = existing + serializeManifestLine(entry)
630
- const problems = validateManifest(newContent, opts)
631
- // 기존이 이미 클린이므로 newContent의 문제는 후보(candidate) 때문. **오직 중복뿐**일 때만 멱등 skip, 하나라도 다른 문제면 fail.
632
- if (problems.length) {
633
- if (problems.every((p) => p.includes('중복'))) {
634
- console.log('[req:commit] design 승인 이미 기록됨(멱등 skip)')
635
- return
636
- }
637
- throw new Error(`design-finalize 매니페스트 검증 실패: ${problems.join('; ')}`)
638
- }
639
- const responsePath = typeof dev.response_path === 'string' ? dev.response_path : ''
640
- mkdirSync(args.responsesDir, { recursive: true })
641
- writeFileSync(args.manifestPath, newContent, 'utf8')
642
- git(['add', responsePath, `${args.ticketRel}/responses/approvals.jsonl`])
643
- const leak = stagedNames().filter((p) => !p.startsWith(`${args.ticketRel}/responses/`))
644
- if (leak.length) throw new Error(`design-finalize 커밋에 responses 외 staged 금지: ${leak.join(', ')}`)
645
- git(['commit', '-m', `chore(${args.state.id}): design-finalize — design 승인 approvals.jsonl 기록`])
646
- console.log('[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록')
647
- }
648
-
649
- export function main(argv: string[] = process.argv.slice(2)): void {
650
- const opts = parseArgs(argv)
651
- const cfg = loadConfig({ root: opts.root })
652
- gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
653
- pkgManager = cfg.packageManager
654
- gitAdapter = createGitAdapter(cfg.root)
655
- const { ticketDir, doctorArgs } = resolveCommitTarget(opts, cfg)
656
- const { run, message, messageFile, finalize, finalizeDesign } = opts
657
- const state = loadState(ticketDir)
658
- const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
659
- const responsesDir = join(ticketDir, 'responses')
660
- const manifestPath = join(responsesDir, 'approvals.jsonl')
661
- const ev = (state.approval_evidence as ApprovalEvidence | undefined) ?? null
662
- const validPhaseIds = readPhases(state).map((p) => p.id)
663
-
664
- // ── DRY-RUN(부작용 없음): 게이트/계획 미리보기 ──
665
- if (!run) {
666
- const gate = userConfirmGate(state)
667
- const mode = finalizeDesign ? 'finalize-design' : finalize ? 'finalize(복구)' : '정상'
668
- console.log(`[req:commit] DRY-RUN (모드=${mode}; 실제 실행은 --run)`)
669
- console.log(` ticket=${ticketRel} commit_allowed=${String(state.commit_allowed)} risk=${String(state.risk_level)}`)
670
- console.log(` HIGH 게이트: ${gate.blocked ? `차단 — ${gate.reason}` : 'OK(또는 비-HIGH)'}`)
671
- if (finalize) {
672
- // P2-a: pending 마커 없어도 HEAD가 승인 source면 orphaned 복구 가능 → dry-run에도 반영.
673
- const head = (() => {
674
- try {
675
- return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
676
- } catch {
677
- return null
678
- }
679
- })()
680
- const rec = resolveRecoverySource(state, head)
681
- let core = { valid: false, reason: rec.reason }
682
- if (rec.sourceSha) {
683
- let sourceTree: string | null = null
684
- try {
685
- sourceTree = git(['rev-parse', `${rec.sourceSha}^{tree}`])
686
- } catch {
687
- sourceTree = null
688
- }
689
- core = recoveryCoreValid(state, sourceTree)
690
- }
691
- console.log(
692
- ` finalize 적용 가능성: ${core.valid ? `valid${rec.viaOrphan ? '(orphaned 복구)' : ''}` : `invalid — ${core.reason}`}`,
693
- )
694
- }
695
- if (ev) {
696
- const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
697
- const expected = expectedArchivePaths(archiveNames, ev.review_kind, ev.phase_id ?? null, ticketRel)
698
- console.log(` approval_evidence: ${ev.review_kind} ${ev.phase_id ?? ''} → evidence-finalize 아카이브 ${expected.length}건`)
699
- } else {
700
- console.log(' approval_evidence 없음(review-codex 승인 후 실행)')
701
- }
702
- if (existsSync(manifestPath)) {
703
- const problems = validateManifest(readFileSync(manifestPath, 'utf8'), { ticketRel, validPhaseIds })
704
- console.log(` approvals.jsonl: ${problems.length ? `문제 ${problems.length} — ${problems.join('; ')}` : 'OK'}`)
705
- }
706
- return
707
- }
708
-
709
- // ── B3: design-finalize(source/consume 없음) ──
710
- if (finalizeDesign) {
711
- designFinalize({ ticketDir, ticketRel, responsesDir, manifestPath, doctorArgs, state, validPhaseIds })
712
- return
713
- }
714
-
715
- const existing = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : ''
716
- const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
717
-
718
- // ── B3: finalize(복구) — source 재커밋 없이 evidence/consume만 복구 ──
719
- if (finalize) {
720
- // P2-a: pending 마커가 없을 수 있다(source 커밋 성공 후 markPendingEvidence 전에 crash). HEAD가 승인 source면 마커를 재구성해 복구.
721
- let fstate = state
722
- if (!pendingSourceSha(fstate)) {
723
- const head = (() => {
724
- try {
725
- return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
726
- } catch {
727
- return null
728
- }
729
- })()
730
- const rec = resolveRecoverySource(fstate, head)
731
- if (!rec.sourceSha) throw new Error(`finalize 거부: ${rec.reason}`)
732
- fstate = markPendingEvidence(fstate, rec.sourceSha) // crash가 막은 마커 재구성(승인 tree 대조로 안전 — 우회 아님)
733
- writeState(ticketDir, fstate)
734
- console.warn(`[req:commit] pending 마커 없음 — HEAD(${rec.sourceSha.slice(0, 8)})가 승인 source(tree==approved)라 orphaned 복구용 마커 재구성`)
735
- }
736
- const sourceSha = pendingSourceSha(fstate) as string
737
- const sourceTree = git(['rev-parse', `${sourceSha}^{tree}`])
738
- const rc = recoveryClassify(fstate, sourceTree)
739
- if (!rc.valid) throw new Error(`finalize 거부: ${rc.reason}`)
740
- if (!ev) throw new Error('approval_evidence 없음') // rc.valid가 보장하나 TS narrowing
741
- // doctor --finalize: D9를 source 커밋 tree로 교체(우회 아님), 나머지 검사 정상.
742
- runDoctor([...doctorArgs, '--finalize'])
743
- const gate = userConfirmGate(fstate)
744
- if (gate.blocked) throw new Error(gate.reason)
745
- finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state: fstate, ev, existing, archiveNames, validPhaseIds, sourceSha })
746
- console.log(`[req:commit] ✅ finalize 복구 완료 — source=${sourceSha.slice(0, 8)} · evidence/consume 복구`)
747
- return
748
- }
749
-
750
- // ── LIVE (B2 정상 flow) — ⚠️ B2 도구 자체 커밋엔 쓰지 않음(부트스트랩). Phase C부터 dogfood. ──
751
- const responsePathExists = !!ev && typeof ev.response_path === 'string' && existsSync(resolve(cfg.root, ev.response_path))
752
-
753
- // 1) doctor 게이트(fail-closed)
754
- runDoctor(doctorArgs)
755
- // 2) HIGH 사람확인 게이트
756
- const gate = userConfirmGate(state)
757
- if (gate.blocked) throw new Error(gate.reason)
758
- // 3) 전제: 승인 존재 + staged tree == approved_diff_hash + staged=코드만(state/responses 금지)
759
- if (state.commit_allowed !== true) throw new Error('commit_allowed=true 아님 — 승인된 phase 없음(req:review-codex 승인 필요)')
760
- if (!ev) throw new Error('approval_evidence 없음 — 승인 증거 미기록')
761
- if (typeof state.approved_diff_hash !== 'string') throw new Error('approved_diff_hash 없음')
762
- const stagedTree = git(['write-tree'])
763
- if (stagedTree !== state.approved_diff_hash)
764
- throw new Error(`staged tree(${stagedTree}) != approved_diff_hash(${state.approved_diff_hash}) — stale 승인, 재리뷰 필요`)
765
- const srcStaged = stagedNames()
766
- if (srcStaged.length === 0) throw new Error('staged 변경 없음 — 승인 코드를 stage 후 실행')
767
- const nonCode = srcStaged.filter((p) => p === `${ticketRel}/state.json` || p.startsWith(`${ticketRel}/responses/`))
768
- if (nonCode.length) throw new Error(`source 커밋에 비-코드 staged 금지(state/responses): ${nonCode.join(', ')}`)
769
- // REQ-018: 메시지 출처 해소(-m 또는 --message-file/env) — 정상 source-커밋 flow에서만. fail-closed(상호배타·필수·존재검증).
770
- const msgSource = resolveMessageSource({ message, messageFile }, process.env.REQ_COMMIT_MESSAGE_FILE, existsSync)
771
- // 3b) evidence preflight(B2-block1/2) — source 커밋 전 잡을 수 있는 evidence 실패 전부 차단. 실패 시 git commit 안 함.
772
- const pre = evidencePreflight({
773
- existingManifest: existing,
774
- approvalEvidence: ev,
775
- archiveNames,
776
- ticketRel,
777
- validPhaseIds,
778
- responsePathExists,
779
- userCommitConfirmed: (state.user_commit_confirmed as unknown) ?? null,
780
- placeholderCommitSha: PREFLIGHT_PLACEHOLDER_OID,
781
- placeholderConsumedAt: PREFLIGHT_PLACEHOLDER_ISO,
782
- })
783
- if (pre.length) throw new Error(`evidence preflight 실패(source 커밋 안 함): ${pre.join('; ')}`)
784
- // 4) source 커밋(승인 코드만) — 여기서부터 부작용. preflight 통과로 source 후 실패 창 최소화.
785
- // REQ-018: -m(메시지) 또는 -F(파일). messageFile 경로는 pnpm/Windows argv newline 이스케이프를 회피.
786
- git(buildCommitArgs(msgSource))
787
- const sourceSha = git(['rev-parse', 'HEAD'])
788
- // 4b) B3 복구 마커 — source 커밋됨, evidence 미완. 이후 중단 시 `req:commit <id> --finalize --run`으로 복구.
789
- writeState(ticketDir, markPendingEvidence(state, sourceSha))
790
- // 5) evidence-finalize(멱등) + 소비(마지막).
791
- finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state, ev, existing, archiveNames, validPhaseIds, sourceSha })
792
- console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
793
- }
794
-
795
- /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
796
- export function runCli(argv: string[]): void {
797
- try {
798
- main(argv)
799
- } catch (err) {
800
- console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
801
- process.exitCode = 1
802
- }
803
- }
804
-
805
- const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
806
- if (isMain) main()
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:commit — AI REQ 워크플로우 Phase B (REQ-2026-016). 승인된 phase를 커밋하는 래퍼.
4
+ *
5
+ * 설계 근거: 본 티켓 01-design.md D-016-3·3b·7·8·9.
6
+ * 책임(전체): req:doctor 통과 게이트 → HIGH 사람확인 게이트 → source 커밋(승인 코드만) →
7
+ * commit_allowed 소비 → evidence-finalize(approvals.jsonl 매니페스트 append + responses chore 커밋) → 2-커밋.
8
+ * 복구/finalize 모드(pending_evidence_for)·design-finalize 포함.
9
+ *
10
+ * **B1: 순수 기반/매니페스트 모델**. **B2(현재): 정상 flow** — doctor 게이트→HIGH→source 커밋→evidence-finalize→소비(2-커밋).
11
+ * 복구/finalize 모드(pending_evidence_for)·design-finalize는 **B3**. main()은 `--run` 없으면 dry-run(부작용 없음).
12
+ * ⚠️ B2 도구 자체 커밋은 부트스트랩 수기(req:commit dogfood는 Phase C부터).
13
+ */
14
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
15
+ import { createHash } from 'node:crypto'
16
+ import { resolve, join, relative } from 'node:path'
17
+ import { pathToFileURL } from 'node:url'
18
+ import {
19
+ loadState,
20
+ writeState,
21
+ readPhases,
22
+ type ApprovalEvidence,
23
+ type ReviewKind,
24
+ type WorkflowState,
25
+ } from './review-codex'
26
+ import { isArchiveFileName } from './lib/scratch'
27
+ import { createEvidencePorts } from './lib/evidence-ports' // 아카이브 파일명 판정의 정본은 scratch(leaf)
28
+ // REQ-2026-048 phase-1: 매니페스트 모델·검증과 그 보조 술어는 leaf `lib/evidence.ts`가 정본.
29
+ // 여기서 **재수출**해 기존 import 경로(`from './req-commit'`)를 쓰던 호출부·테스트를 그대로 둔다.
30
+ import {
31
+ archiveBaseName,
32
+ buildArchiveInventory,
33
+ designEvidenceStagePaths,
34
+ durableDesignEvidence,
35
+ isConfinedArchivePath,
36
+ isValidIsoInstant,
37
+ buildManifestEntry,
38
+ expectedArchivePaths,
39
+ manifestHasConsumed,
40
+ serializeManifestLine,
41
+ userConfirmProblem,
42
+ validateManifest,
43
+ type ManifestEntry,
44
+ type UserCommitConfirmed,
45
+ } from './lib/evidence'
46
+ export {
47
+ buildArchiveInventory,
48
+ buildManifestEntry,
49
+ designEvidenceStagePaths,
50
+ expectedArchivePaths,
51
+ manifestHasConsumed,
52
+ serializeManifestLine,
53
+ userConfirmProblem,
54
+ validateManifest,
55
+ type ManifestEntry,
56
+ type UserCommitConfirmed,
57
+ type ArchiveInventoryItem,
58
+ } from './lib/evidence'
59
+ import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type PackageManager, type ResolvedConfig } from './lib/config'
60
+ import { createGitAdapter, safeSpawnSync, type GitAdapter } from './lib/adapters'
61
+
62
+ // git=GitAdapter 경유(D-017-3), 패키지매니저=config. runDoctor(pnpm/npm 실행)는 cwd=gitRoot 필요(비-git 호출). main()이 loadConfig 후 config.root로 설정.
63
+ let gitRoot = packageRoot()
64
+ let pkgManager: PackageManager = DEFAULTS.packageManager
65
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
66
+
67
+ /**
68
+ * repo-상대 경로 파일의 sha256(hex). `lib/evidence`는 fs를 모르는 순수 모듈이라 여기서 주입한다.
69
+ * 🔴 `gitRoot`는 `main()`이 `cfg.root`로 세팅한 뒤에만 유효하다 — designFinalize는 그 이후에만 호출된다.
70
+ */
71
+ function repoRelSha256(repoRel: string): string {
72
+ return createHash('sha256').update(readFileSync(join(gitRoot, ...repoRel.split('/')))).digest('hex')
73
+ }
74
+ // evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
75
+ const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
76
+ const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
77
+
78
+
79
+ // ───────────────────────────────── approvals.jsonl 매니페스트 모델 (B1) ──
80
+
81
+
82
+
83
+
84
+
85
+
86
+ // ─────────────────────────────────────── B2: HIGH 게이트 / 소비 / preflight(순수) ──
87
+
88
+
89
+ /**
90
+ * HIGH 사람확인 게이트(D-016-8, 순수). HIGH인데 유효한 `user_commit_confirmed`(confirmed=true·method·ISO confirmed_at)가 없으면 차단.
91
+ */
92
+ export function userConfirmGate(state: WorkflowState): { blocked: boolean; reason?: string } {
93
+ if (state.risk_level !== 'HIGH') return { blocked: false }
94
+ const problem = userConfirmProblem(state.user_commit_confirmed)
95
+ if (!problem) return { blocked: false }
96
+ return {
97
+ blocked: true,
98
+ reason: `HIGH risk: user_commit_confirmed ${problem} — req:commit 차단(감사 기록이며 위조불가 증명 아님; 가장 강한 보장=사용자가 직접 실행).`,
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 승인 소비(D-016-9, 순수). **evidence 커밋 성공 후 마지막**에만 호출.
104
+ * commit_allowed=false · approved_diff_hash=null · consumed_approvals[] append · user_commit_confirmed 초기화 · approval_evidence 핀 제거.
105
+ */
106
+ export function consumeState(state: WorkflowState, opts: { sourceCommitSha: string; consumedAt: string }): WorkflowState {
107
+ const rawPrev = (state as { consumed_approvals?: unknown }).consumed_approvals
108
+ const prev = Array.isArray(rawPrev) ? rawPrev : []
109
+ const entry = {
110
+ approved_tree: typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null,
111
+ phase_id: typeof state.current_phase === 'string' ? state.current_phase : null,
112
+ consumed_by_commit_sha: opts.sourceCommitSha,
113
+ approval_consumed_at: opts.consumedAt,
114
+ }
115
+ // approval_evidence(현재 pending 승인 핀) + pending_evidence_for(복구 마커)는 소비와 함께 제거(다음 리뷰가 재부착).
116
+ const { approval_evidence: _consumed, pending_evidence_for: _pending, ...rest } = state
117
+ return {
118
+ ...rest,
119
+ commit_allowed: false,
120
+ approved_diff_hash: null,
121
+ consumed_approvals: [...prev, entry],
122
+ user_commit_confirmed: null,
123
+ }
124
+ }
125
+
126
+ // ─────────────────────────────────────────────── B3: 복구/finalize(순수) ──
127
+
128
+ /**
129
+ * 복구 마커 부착(순수, B3). **source 커밋 직후·evidence-finalize 전**에 기록 → 이후 중단 시 finalize로 복구.
130
+ * approval_evidence는 그대로(소비 전), pending_evidence_for.source_commit_sha로 "source 커밋됨, evidence 미완"을 표시.
131
+ */
132
+ export function markPendingEvidence(state: WorkflowState, sourceCommitSha: string): WorkflowState {
133
+ return { ...state, pending_evidence_for: { source_commit_sha: sourceCommitSha } }
134
+ }
135
+
136
+ /** state.pending_evidence_for.source_commit_sha 추출(순수). 없으면 null. */
137
+ export function pendingSourceSha(state: WorkflowState): string | null {
138
+ const pending = (state as { pending_evidence_for?: unknown }).pending_evidence_for
139
+ if (!pending || typeof pending !== 'object') return null
140
+ const sha = (pending as { source_commit_sha?: unknown }).source_commit_sha
141
+ return typeof sha === 'string' && sha ? sha : null
142
+ }
143
+
144
+ /**
145
+ * `req:commit --finalize` 적용 가능성 사전판정(순수, B3). source 미커밋 비-복구 상태에서 finalize 오용 차단.
146
+ * ⚠️ B3-P1: HEAD가 아니라 **pending_evidence_for.source_commit_sha의 source 커밋 tree**를 approved와 대조.
147
+ * (evidence 커밋 후엔 HEAD=evidence 커밋이라 HEAD^{tree}≠approved → consume-only 복구창을 막아버리던 결함 수정.)
148
+ * valid 조건: pending 마커 존재 · commit_allowed===true · approval_evidence 존재 · approved_diff_hash 문자열 · **sourceCommitTree == approved_diff_hash**.
149
+ */
150
+ export function recoveryClassify(state: WorkflowState, sourceCommitTree: string | null): { valid: boolean; reason: string } {
151
+ if (!pendingSourceSha(state)) return { valid: false, reason: 'pending_evidence_for.source_commit_sha 없음 — 복구할 미완 작업 없음' }
152
+ return recoveryCoreValid(state, sourceCommitTree)
153
+ }
154
+
155
+ /**
156
+ * 복구 유효성 코어(순수, pending 마커 유무와 무관): commit_allowed·approval_evidence·approved_diff_hash·**sourceCommitTree == approved_diff_hash**.
157
+ * recoveryClassify(마커 필수)와 resolveRecoverySource(orphan 복구)가 공용으로 쓴다.
158
+ */
159
+ export function recoveryCoreValid(state: WorkflowState, sourceCommitTree: string | null): { valid: boolean; reason: string } {
160
+ if (state.commit_allowed !== true) return { valid: false, reason: 'commit_allowed=true 아님 — 복구할 미완 승인 없음' }
161
+ if (!state.approval_evidence) return { valid: false, reason: 'approval_evidence 없음' }
162
+ const approved = typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null
163
+ if (!approved) return { valid: false, reason: 'approved_diff_hash 없음' }
164
+ if (sourceCommitTree !== approved)
165
+ return { valid: false, reason: `source 커밋 tree(${String(sourceCommitTree)}) != approved(${approved}) 잘못된 복구 대상` }
166
+ return { valid: true, reason: 'finalize 유효: source 커밋 tree == approved, evidence/consume 복구 가능' }
167
+ }
168
+
169
+ /**
170
+ * finalize 복구 대상 source SHA 해소(순수, P2-a — marker 기록 전 crash 복구창).
171
+ * pending 마커 있으면 SHA(viaOrphan=false).
172
+ * 마커 없어도 HEAD가 승인 source(head.tree == approved_diff_hash + commit_allowed + approval_evidence)면 orphaned source로 HEAD 복구(viaOrphan=true).
173
+ * ⚠️ 승인 tree 대조라 **승인 우회 아님** source 커밋 성공 후 markPendingEvidence 전에 죽은 상태만 복구한다.
174
+ */
175
+ export function resolveRecoverySource(
176
+ state: WorkflowState,
177
+ head: { sha: string; tree: string } | null,
178
+ ): { sourceSha: string | null; viaOrphan: boolean; reason: string } {
179
+ const pending = pendingSourceSha(state)
180
+ if (pending) return { sourceSha: pending, viaOrphan: false, reason: 'pending 마커' }
181
+ if (!head) return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD 미상 — 복구할 미완 작업 없음' }
182
+ const approved = typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null
183
+ if (state.commit_allowed === true && !!state.approval_evidence && approved !== null && head.tree === approved)
184
+ return { sourceSha: head.sha, viaOrphan: true, reason: 'orphaned source(HEAD tree == approved) 복구' }
185
+ return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD가 승인 source 아님 — 복구할 미완 작업 없음' }
186
+ }
187
+
188
+
189
+ export interface PreflightInput {
190
+ existingManifest: string // 현재 approvals.jsonl 내용('' = 없음)
191
+ approvalEvidence: ApprovalEvidence | null
192
+ archiveNames: string[] // readdir(responses).filter(isArchiveFileName)
193
+ ticketRel: string
194
+ validPhaseIds: string[]
195
+ responsePathExists: boolean // existsSync(approval_evidence.response_path)
196
+ userCommitConfirmed: unknown // state.user_commit_confirmed (후보 entry 구성용)
197
+ placeholderCommitSha: string // 구조 사전검증용 valid OID(실제 sourceSha는 source 후)
198
+ placeholderConsumedAt: string // 구조 사전검증용 valid ISO
199
+ }
200
+
201
+ /**
202
+ * **source 커밋 전** evidence preflight(순수, B2-block1/2). 커밋 없이 잡을 수 있는 모든 evidence 실패를 먼저 수집.
203
+ * 빈 배열 = 통과. 하나라도 있으면 git commit을 절대 실행하지 않는다(= source 후 실패 창 최소화).
204
+ * 검사: (a) 기존 매니페스트 무결성 · (b) approval_evidence 존재/형식 · (c) expected 아카이브에 approved≥1 & 전부 confined ·
205
+ * (d) response_path가 expected에 포함 · (e) response_path 파일 실제 존재 ·
206
+ * (f) placeholder sourceSha로 후보 entry 빌드+전체 매니페스트 재검증(중복/구조 사전 차단).
207
+ */
208
+ export function evidencePreflight(inp: PreflightInput): string[] {
209
+ const problems: string[] = []
210
+ const opts = { ticketRel: inp.ticketRel, validPhaseIds: inp.validPhaseIds }
211
+ // (a) 기존 approvals.jsonl 무결성
212
+ if (inp.existingManifest.trim()) {
213
+ const p = validateManifest(inp.existingManifest, opts)
214
+ if (p.length) problems.push(`기존 approvals.jsonl 무결성 실패: ${p.join('; ')}`)
215
+ }
216
+ // (b) approval_evidence 존재/형식
217
+ const ev = inp.approvalEvidence
218
+ if (!ev) {
219
+ problems.push('approval_evidence 없음')
220
+ return problems
221
+ }
222
+ if (ev.review_kind !== 'phase' && ev.review_kind !== 'design')
223
+ problems.push(`approval_evidence.review_kind 비유효: ${String(ev.review_kind)}`)
224
+ if (typeof ev.response_path !== 'string' || !ev.response_path) {
225
+ problems.push('approval_evidence.response_path 없음')
226
+ return problems
227
+ }
228
+ // (c) expected 아카이브(target 한정)
229
+ const expected = expectedArchivePaths(inp.archiveNames, ev.review_kind, ev.phase_id ?? null, inp.ticketRel)
230
+ if (!expected.some((p) => /-r\d{2,}-approved\.json$/.test(p)))
231
+ problems.push('expectedArchivePaths에 approved 아카이브 없음(needs-fix만 존재 가능)')
232
+ for (const p of expected) if (!isConfinedArchivePath(p, inp.ticketRel)) problems.push(`archive 경로 비confined: ${p}`)
233
+ // (d) response_path가 expected에 포함
234
+ if (!expected.includes(ev.response_path)) problems.push(`approval_evidence.response_path가 expectedArchivePaths에 없음: ${ev.response_path}`)
235
+ // (e) response_path 파일 실제 존재
236
+ if (!inp.responsePathExists) problems.push(`approval_evidence.response_path 파일 부재: ${ev.response_path}`)
237
+ // (f) placeholder sourceSha로 후보 entry 빌드 + 전체 매니페스트 재검증(source 실패 최소화)
238
+ try {
239
+ const candidate = buildManifestEntry(ev, {
240
+ consumedAt: inp.placeholderConsumedAt,
241
+ consumedByCommitSha: inp.placeholderCommitSha,
242
+ userCommitConfirmed: (inp.userCommitConfirmed as UserCommitConfirmed | null) ?? null,
243
+ })
244
+ const p = validateManifest(inp.existingManifest + serializeManifestLine(candidate), opts)
245
+ if (p.length) problems.push(`후보 manifest entry 검증 실패: ${p.join('; ')}`)
246
+ } catch (e) {
247
+ problems.push(`buildManifestEntry 실패: ${(e as Error).message}`)
248
+ }
249
+ return problems
250
+ }
251
+
252
+ // ─────────────────────────────────────────── CLI (B2: 정상 req:commit flow) ──
253
+
254
+ export interface CommitArgs {
255
+ ticket: string | null
256
+ reqId: string | null
257
+ run: boolean
258
+ message: string | null
259
+ messageFile: string | null // REQ-018: --message-file <path>(→ git commit -F). multi-line 메시지를 argv 거치지 않고 전달
260
+ finalize: boolean // B3: source 재커밋 없이 evidence/consume만 복구
261
+ finalizeDesign: boolean // B3: design 승인을 approvals.jsonl에 기록(source/consume 없음)
262
+ root: string | null // config 탐색 루트(--root)
263
+ }
264
+
265
+ /** CLI 파싱(fail-closed). `--ticket`·`--run`·`--message/-m`·`--message-file`·`--finalize`·`--finalize-design`·`--root <dir>`. 값 누락·알 수 없는 옵션은 throw(메시지 상호배타/필수는 resolveMessageSource). */
266
+ export function parseArgs(argv: string[]): CommitArgs {
267
+ let ticket: string | null = null
268
+ let reqId: string | null = null
269
+ let run = false
270
+ let message: string | null = null
271
+ let messageFile: string | null = null
272
+ let finalize = false
273
+ let finalizeDesign = false
274
+ let root: string | null = null
275
+ for (let i = 0; i < argv.length; i++) {
276
+ const a = argv[i]
277
+ if (a === undefined) continue
278
+ // bare `--`는 POSIX end-of-options 마커(DEC-011-3). ⚠️ 이후 인자도 계속 옵션으로 파싱해야 한다 —
279
+ // 전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run으로 끝난다(가장 나쁜 실패).
280
+ if (a === '--') continue
281
+ else if (a === '--ticket') ticket = argv[++i] ?? null
282
+ else if (a === '--run') run = true
283
+ else if (a === '--message' || a === '-m') message = argv[++i] ?? null
284
+ else if (a === '--message-file') {
285
+ const v = argv[++i]
286
+ if (v === undefined) throw new Error('--message-file 값 필요')
287
+ messageFile = v
288
+ } else if (a === '--finalize') finalize = true
289
+ else if (a === '--finalize-design') finalizeDesign = true
290
+ else if (a === '--root') {
291
+ const v = argv[++i]
292
+ if (v === undefined) throw new Error('--root 값 필요')
293
+ root = v
294
+ } else if (a.startsWith('-')) throw new Error(`알 없는 옵션: ${a}`)
295
+ else reqId = a
296
+ }
297
+ if (finalize && finalizeDesign) throw new Error('--finalize --finalize-design 동시 사용 불가')
298
+ if (!ticket && !reqId) throw new Error('REQ id 또는 --ticket <dir> 필요')
299
+ return { ticket, reqId, run, message, messageFile, finalize, finalizeDesign, root }
300
+ }
301
+
302
+ /**
303
+ * 커밋 메시지 출처 해소(순수, REQ-018 D-018-3). **정상 source-커밋 flow 직전에만** 호출.
304
+ * env fallback(CLI 없을 때만 REQ_COMMIT_MESSAGE_FILE) 상호배타·필수 → **절대경로 정규화** → 존재검증.
305
+ * ⚠️ messageFile은 `resolve()`로 절대경로화: existsFn(=existsSync)은 process.cwd 기준, git `-F`는 cwd=gitRoot라
306
+ * 상대경로면 검증 위치와 git 읽기 위치가 어긋난다(CLI/env 동일 처리). existsFn 주입(테스트=fake).
307
+ */
308
+ export function resolveMessageSource(
309
+ opts: { message: string | null; messageFile: string | null },
310
+ env: string | undefined,
311
+ existsFn: (p: string) => boolean,
312
+ ): { message: string | null; messageFile: string | null } {
313
+ const { message } = opts
314
+ let messageFile = opts.messageFile
315
+ if (message === null && messageFile === null && env !== undefined && env !== '') messageFile = env // env fallback(CLI 우선)
316
+ if (message !== null && messageFile !== null)
317
+ throw new Error('-m/--message --message-file/REQ_COMMIT_MESSAGE_FILE 동시 지정 불가')
318
+ if (message === null && messageFile === null)
319
+ throw new Error('커밋 메시지 필요 -m <msg> 또는 --message-file <path>(또는 REQ_COMMIT_MESSAGE_FILE)')
320
+ if (messageFile !== null) {
321
+ const abs = resolve(messageFile) // 절대경로 정규화(existsFn↔git cwd 위치 일관)
322
+ if (!existsFn(abs)) throw new Error(`--message-file 경로 없음: ${abs}`)
323
+ messageFile = abs
324
+ }
325
+ return { message, messageFile }
326
+ }
327
+
328
+ /**
329
+ * source 커밋 git args 빌더(순수, REQ-018 D-018-3). messageFile→`-F`(메시지 내용이 argv에 없음), message→`-m`.
330
+ * 다/둘 아님 throw(방어 정상 경로는 resolveMessageSource가 보장).
331
+ */
332
+ export function buildCommitArgs(opts: { message: string | null; messageFile: string | null }): string[] {
333
+ if (opts.message !== null && opts.messageFile !== null)
334
+ throw new Error('buildCommitArgs: message·messageFile 동시 불가(방어)')
335
+ if (opts.messageFile !== null) return ['commit', '-F', opts.messageFile]
336
+ if (opts.message !== null) return ['commit', '-m', opts.message]
337
+ throw new Error('buildCommitArgs: message 또는 messageFile 필요')
338
+ }
339
+
340
+ /**
341
+ * 티켓 디렉터리 + req:doctor 인자 해소(순수, config.workflowDirAbs 기준).
342
+ * doctorArgs에 **--root cfg.root 전파** — 자식 req:doctor가 부모와 동일 root를 쓰도록(일관).
343
+ */
344
+ export function resolveCommitTarget(opts: CommitArgs, cfg: ResolvedConfig): { ticketDir: string; doctorArgs: string[] } {
345
+ const rootArgs = ['--root', cfg.root]
346
+ if (opts.ticket) {
347
+ const ticketDir = resolve(opts.ticket)
348
+ return { ticketDir, doctorArgs: ['--ticket', ticketDir, ...rootArgs] }
349
+ }
350
+ const norm = (opts.reqId as string).replace(/^REQ-/, '')
351
+ return { ticketDir: join(cfg.workflowDirAbs, `REQ-${norm}`), doctorArgs: [norm, ...rootArgs] }
352
+ }
353
+
354
+ /** git 실행(GitAdapter 경유, config.root 기준). 실패 시 throw(fail-closed). */
355
+ function git(args: string[]): string {
356
+ return gitAdapter.exec(args)
357
+ }
358
+
359
+ /** req:doctor 게이트 — 별도 프로세스로 실행, exit≠0이면 throw(통과 못 하면 커밋 진입 불가). 패키지매니저별 argv는 buildScriptInvocation(npm은 `run --`). */
360
+ function runDoctor(doctorArgs: string[]): void {
361
+ const [cmd, ...rest] = buildScriptInvocation(pkgManager, 'req:doctor', doctorArgs)
362
+ if (!cmd) throw new Error('buildScriptInvocation: 빈 호출(패키지매니저 설정 오류)')
363
+ // shell 없이 안전 실행(P1): pkg manager는 Windows에서 .cmd라 과거 shell:true였고 doctorArgs(reqId·root 경로)의 메타문자로 주입 가능했음.
364
+ safeSpawnSync(cmd, rest, { cwd: gitRoot, stdio: 'inherit' })
365
+ }
366
+
367
+ /** `git diff --cached --name-only`를 정규화 경로 배열로. */
368
+ function stagedNames(): string[] {
369
+ return git(['diff', '--cached', '--name-only'])
370
+ .split('\n')
371
+ .map((p) => p.trim().replace(/\\/g, '/'))
372
+ .filter(Boolean)
373
+ }
374
+
375
+ interface FinalizeCtx {
376
+ ticketDir: string
377
+ ticketRel: string
378
+ responsesDir: string
379
+ manifestPath: string
380
+ state: WorkflowState
381
+ ev: ApprovalEvidence
382
+ existing: string
383
+ archiveNames: string[]
384
+ validPhaseIds: string[]
385
+ sourceSha: string
386
+ }
387
+
388
+ /**
389
+ * evidence-finalize(**멱등**) + 소비 정상 flow와 `--finalize` 복구가 공유.
390
+ * 이미 sourceSha가 매니페스트에 있으면(=evidence 커밋은 됐고 consume만 못 함) append/chore를 skip하고 소비만 수행.
391
+ * 소비는 항상 마지막. state.json은 scratch 유지(커밋 함).
392
+ */
393
+ function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
394
+ // B3-R2: skip(소비-only) 경로 포함 — 변조된 매니페스트 위에서 consume 금지. 기존 무결성 먼저(복구 모드엔 preflight가 없으므로 필수).
395
+ if (ctx.existing.trim()) {
396
+ const ep = validateManifest(ctx.existing, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
397
+ if (ep.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${ep.join('; ')}`)
398
+ }
399
+ const already = manifestHasConsumed(ctx.existing, ctx.sourceSha, {
400
+ reviewKind: ctx.ev.review_kind,
401
+ phaseId: ctx.ev.phase_id ?? null,
402
+ responseSha256: ctx.ev.response_sha256,
403
+ })
404
+ if (!already) {
405
+ const entry = buildManifestEntry(ctx.ev, {
406
+ consumedAt: new Date().toISOString(),
407
+ consumedByCommitSha: ctx.sourceSha,
408
+ userCommitConfirmed: (ctx.state.user_commit_confirmed as UserCommitConfirmed | null) ?? null,
409
+ })
410
+ const newContent = ctx.existing + serializeManifestLine(entry)
411
+ const reproblems = validateManifest(newContent, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
412
+ if (reproblems.length)
413
+ throw new Error(`(예상외) 매니페스트 검증 실패: ${reproblems.join('; ')} — source=${ctx.sourceSha} 커밋됨, --finalize로 복구`)
414
+ mkdirSync(ctx.responsesDir, { recursive: true })
415
+ writeFileSync(ctx.manifestPath, newContent, 'utf8')
416
+ const archivePaths = expectedArchivePaths(ctx.archiveNames, ctx.ev.review_kind, ctx.ev.phase_id ?? null, ctx.ticketRel)
417
+ git(['add', ...archivePaths, `${ctx.ticketRel}/responses/approvals.jsonl`])
418
+ const choreLeak = stagedNames().filter((p) => !p.startsWith(`${ctx.ticketRel}/responses/`))
419
+ if (choreLeak.length) throw new Error(`evidence 커밋에 responses 외 staged 금지(코드/state 누수): ${choreLeak.join(', ')}`)
420
+ git(['commit', '-m', `chore(${ctx.state.id}): evidence-finalize ${ctx.ev.review_kind} ${ctx.ev.phase_id ?? ''} 아카이브·approvals.jsonl`])
421
+ } else {
422
+ console.log('[req:commit] evidence 이미 finalize됨(멱등 skip) — 소비만 수행')
423
+ }
424
+ // 소비(마지막) — commit_allowed=false·approved_diff_hash=null·pending 마커 제거.
425
+ writeState(ctx.ticketDir, consumeState(ctx.state, { sourceCommitSha: ctx.sourceSha, consumedAt: new Date().toISOString() }))
426
+ }
427
+
428
+ /**
429
+ * design-finalize(B3) — design 승인을 approvals.jsonl에 audit 기록(source 커밋·commit_allowed 소비 없음).
430
+ * 멱등: 동일 design 엔트리(kind/sha 중복)면 skip. doctor는 정상 실행(우회 아님).
431
+ */
432
+ function designFinalize(args: {
433
+ ticketDir: string
434
+ ticketRel: string
435
+ responsesDir: string
436
+ manifestPath: string
437
+ doctorArgs: string[]
438
+ state: WorkflowState
439
+ validPhaseIds: string[]
440
+ }): void {
441
+ const dev = (args.state.design_approval_evidence as ApprovalEvidence | undefined) ?? null
442
+ if (!dev) throw new Error('design_approval_evidence 없음 — design 승인 후 실행')
443
+ if (dev.review_kind !== 'design') throw new Error(`design_approval_evidence.review_kind != design: ${String(dev.review_kind)}`)
444
+ runDoctor(args.doctorArgs) // design-finalize도 doctor 우회 금지(정상)
445
+ // REQ-2026-048 phase-3: 실제 내구화는 **공유 구현**에 위임한다. 정상 승인 경로(review-codex)와
446
+ // 복구 경로가 같은 함수를 부르므로 동작이 갈라질 수 없다(DEC-1·DEC-3).
447
+ const r = durableDesignEvidence({
448
+ ticketId: String(args.state.id ?? ''),
449
+ ticketRel: args.ticketRel,
450
+ evidence: dev,
451
+ validPhaseIds: args.validPhaseIds,
452
+ nowIso: new Date().toISOString(),
453
+ ports: createEvidencePorts(gitRoot, `${args.ticketRel}/responses`),
454
+ })
455
+ if (r.outcome === 'already-durable') {
456
+ console.log('[req:commit] design 승인 이미 내구화됨(HEAD 기준 멱등 skip)')
457
+ return
458
+ }
459
+ console.log(
460
+ r.outcome === 'recommitted'
461
+ ? '[req:commit] design-finalize 복구 완료 매니페스트는 이미 있었고 커밋만 누락돼 재커밋했습니다'
462
+ : '[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록',
463
+ )
464
+ }
465
+
466
+ export function main(argv: string[] = process.argv.slice(2)): void {
467
+ const opts = parseArgs(argv)
468
+ const cfg = loadConfig({ root: opts.root })
469
+ gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
470
+ pkgManager = cfg.packageManager
471
+ gitAdapter = createGitAdapter(cfg.root)
472
+ const { ticketDir, doctorArgs } = resolveCommitTarget(opts, cfg)
473
+ const { run, message, messageFile, finalize, finalizeDesign } = opts
474
+ const state = loadState(ticketDir)
475
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
476
+ const responsesDir = join(ticketDir, 'responses')
477
+ const manifestPath = join(responsesDir, 'approvals.jsonl')
478
+ const ev = (state.approval_evidence as ApprovalEvidence | undefined) ?? null
479
+ const validPhaseIds = readPhases(state).map((p) => p.id)
480
+
481
+ // ── DRY-RUN(부작용 없음): 게이트/계획 미리보기 ──
482
+ if (!run) {
483
+ const gate = userConfirmGate(state)
484
+ const mode = finalizeDesign ? 'finalize-design' : finalize ? 'finalize(복구)' : '정상'
485
+ console.log(`[req:commit] DRY-RUN (모드=${mode}; 실제 실행은 --run)`)
486
+ console.log(` ticket=${ticketRel} commit_allowed=${String(state.commit_allowed)} risk=${String(state.risk_level)}`)
487
+ console.log(` HIGH 게이트: ${gate.blocked ? `차단 ${gate.reason}` : 'OK(또는 비-HIGH)'}`)
488
+ if (finalize) {
489
+ // P2-a: pending 마커 없어도 HEAD가 승인 source면 orphaned 복구 가능 → dry-run에도 반영.
490
+ const head = (() => {
491
+ try {
492
+ return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
493
+ } catch {
494
+ return null
495
+ }
496
+ })()
497
+ const rec = resolveRecoverySource(state, head)
498
+ let core = { valid: false, reason: rec.reason }
499
+ if (rec.sourceSha) {
500
+ let sourceTree: string | null = null
501
+ try {
502
+ sourceTree = git(['rev-parse', `${rec.sourceSha}^{tree}`])
503
+ } catch {
504
+ sourceTree = null
505
+ }
506
+ core = recoveryCoreValid(state, sourceTree)
507
+ }
508
+ console.log(
509
+ ` finalize 적용 가능성: ${core.valid ? `valid${rec.viaOrphan ? '(orphaned 복구)' : ''}` : `invalid — ${core.reason}`}`,
510
+ )
511
+ }
512
+ if (ev) {
513
+ const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
514
+ const expected = expectedArchivePaths(archiveNames, ev.review_kind, ev.phase_id ?? null, ticketRel)
515
+ console.log(` approval_evidence: ${ev.review_kind} ${ev.phase_id ?? ''} → evidence-finalize 아카이브 ${expected.length}건`)
516
+ } else {
517
+ console.log(' approval_evidence 없음(review-codex 승인 실행)')
518
+ }
519
+ if (existsSync(manifestPath)) {
520
+ const problems = validateManifest(readFileSync(manifestPath, 'utf8'), { ticketRel, validPhaseIds })
521
+ console.log(` approvals.jsonl: ${problems.length ? `문제 ${problems.length} — ${problems.join('; ')}` : 'OK'}`)
522
+ }
523
+ return
524
+ }
525
+
526
+ // ── B3: design-finalize(source/consume 없음) ──
527
+ if (finalizeDesign) {
528
+ designFinalize({ ticketDir, ticketRel, responsesDir, manifestPath, doctorArgs, state, validPhaseIds })
529
+ return
530
+ }
531
+
532
+ const existing = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : ''
533
+ const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
534
+
535
+ // ── B3: finalize(복구) source 재커밋 없이 evidence/consume만 복구 ──
536
+ if (finalize) {
537
+ // P2-a: pending 마커가 없을 있다(source 커밋 성공 후 markPendingEvidence 전에 crash). HEAD가 승인 source면 마커를 재구성해 복구.
538
+ let fstate = state
539
+ if (!pendingSourceSha(fstate)) {
540
+ const head = (() => {
541
+ try {
542
+ return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
543
+ } catch {
544
+ return null
545
+ }
546
+ })()
547
+ const rec = resolveRecoverySource(fstate, head)
548
+ if (!rec.sourceSha) throw new Error(`finalize 거부: ${rec.reason}`)
549
+ fstate = markPendingEvidence(fstate, rec.sourceSha) // crash가 막은 마커 재구성(승인 tree 대조로 안전 — 우회 아님)
550
+ writeState(ticketDir, fstate)
551
+ console.warn(`[req:commit] pending 마커 없음 — HEAD(${rec.sourceSha.slice(0, 8)})가 승인 source(tree==approved)라 orphaned 복구용 마커 재구성`)
552
+ }
553
+ const sourceSha = pendingSourceSha(fstate) as string
554
+ const sourceTree = git(['rev-parse', `${sourceSha}^{tree}`])
555
+ const rc = recoveryClassify(fstate, sourceTree)
556
+ if (!rc.valid) throw new Error(`finalize 거부: ${rc.reason}`)
557
+ if (!ev) throw new Error('approval_evidence 없음') // rc.valid가 보장하나 TS narrowing
558
+ // doctor --finalize: D9를 source 커밋 tree로 교체(우회 아님), 나머지 검사 정상.
559
+ runDoctor([...doctorArgs, '--finalize'])
560
+ const gate = userConfirmGate(fstate)
561
+ if (gate.blocked) throw new Error(gate.reason)
562
+ finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state: fstate, ev, existing, archiveNames, validPhaseIds, sourceSha })
563
+ console.log(`[req:commit] ✅ finalize 복구 완료 — source=${sourceSha.slice(0, 8)} · evidence/consume 복구`)
564
+ return
565
+ }
566
+
567
+ // ── LIVE (B2 정상 flow) — ⚠️ B2 도구 자체 커밋엔 쓰지 않음(부트스트랩). Phase C부터 dogfood. ──
568
+ const responsePathExists = !!ev && typeof ev.response_path === 'string' && existsSync(resolve(cfg.root, ev.response_path))
569
+
570
+ // 1) doctor 게이트(fail-closed)
571
+ runDoctor(doctorArgs)
572
+ // 2) HIGH 사람확인 게이트
573
+ const gate = userConfirmGate(state)
574
+ if (gate.blocked) throw new Error(gate.reason)
575
+ // 3) 전제: 승인 존재 + staged tree == approved_diff_hash + staged=코드만(state/responses 금지)
576
+ if (state.commit_allowed !== true) throw new Error('commit_allowed=true 아님 — 승인된 phase 없음(req:review-codex 승인 필요)')
577
+ if (!ev) throw new Error('approval_evidence 없음 — 승인 증거 미기록')
578
+ if (typeof state.approved_diff_hash !== 'string') throw new Error('approved_diff_hash 없음')
579
+ const stagedTree = git(['write-tree'])
580
+ if (stagedTree !== state.approved_diff_hash)
581
+ throw new Error(`staged tree(${stagedTree}) != approved_diff_hash(${state.approved_diff_hash}) — stale 승인, 재리뷰 필요`)
582
+ const srcStaged = stagedNames()
583
+ if (srcStaged.length === 0) throw new Error('staged 변경 없음 — 승인 코드를 stage 후 실행')
584
+ const nonCode = srcStaged.filter((p) => p === `${ticketRel}/state.json` || p.startsWith(`${ticketRel}/responses/`))
585
+ if (nonCode.length) throw new Error(`source 커밋에 비-코드 staged 금지(state/responses): ${nonCode.join(', ')}`)
586
+ // REQ-018: 메시지 출처 해소(-m 또는 --message-file/env) 정상 source-커밋 flow에서만. fail-closed(상호배타·필수·존재검증).
587
+ const msgSource = resolveMessageSource({ message, messageFile }, process.env.REQ_COMMIT_MESSAGE_FILE, existsSync)
588
+ // 3b) evidence preflight(B2-block1/2) source 커밋 잡을 있는 evidence 실패 전부 차단. 실패 시 git commit 안 함.
589
+ const pre = evidencePreflight({
590
+ existingManifest: existing,
591
+ approvalEvidence: ev,
592
+ archiveNames,
593
+ ticketRel,
594
+ validPhaseIds,
595
+ responsePathExists,
596
+ userCommitConfirmed: (state.user_commit_confirmed as unknown) ?? null,
597
+ placeholderCommitSha: PREFLIGHT_PLACEHOLDER_OID,
598
+ placeholderConsumedAt: PREFLIGHT_PLACEHOLDER_ISO,
599
+ })
600
+ if (pre.length) throw new Error(`evidence preflight 실패(source 커밋 안 함): ${pre.join('; ')}`)
601
+ // 4) source 커밋(승인 코드만) — 여기서부터 부작용. preflight 통과로 source 후 실패 창 최소화.
602
+ // REQ-018: -m(메시지) 또는 -F(파일). messageFile 경로는 pnpm/Windows argv newline 이스케이프를 회피.
603
+ git(buildCommitArgs(msgSource))
604
+ const sourceSha = git(['rev-parse', 'HEAD'])
605
+ // 4b) B3 복구 마커 source 커밋됨, evidence 미완. 이후 중단 시 `req:commit <id> --finalize --run`으로 복구.
606
+ writeState(ticketDir, markPendingEvidence(state, sourceSha))
607
+ // 5) evidence-finalize(멱등) + 소비(마지막).
608
+ finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state, ev, existing, archiveNames, validPhaseIds, sourceSha })
609
+ console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
610
+ }
611
+
612
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
613
+ export function runCli(argv: string[]): void {
614
+ try {
615
+ main(argv)
616
+ } catch (err) {
617
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
618
+ process.exitCode = 1
619
+ }
620
+ }
621
+
622
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
623
+ if (isMain) main()