commitgate 0.9.8 → 0.9.10

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