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