commitgate 0.9.6 → 0.9.9

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.
@@ -12,20 +12,56 @@
12
12
  * ⚠️ B2 도구 자체 커밋은 부트스트랩 수기(req:commit dogfood는 Phase C부터).
13
13
  */
14
14
  import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
15
+ import { createHash } from 'node:crypto'
15
16
  import { resolve, join, relative } from 'node:path'
16
17
  import { pathToFileURL } from 'node:url'
17
18
  import {
18
19
  loadState,
19
20
  writeState,
20
21
  readPhases,
21
- archiveBaseName,
22
- isArchiveFileName,
23
- isValidIsoInstant,
22
+ appendCloseProofRowToDisk,
24
23
  type ApprovalEvidence,
25
24
  type ReviewKind,
26
25
  type WorkflowState,
27
26
  } from './review-codex'
28
- import { isConfinedArchivePath } from './req-doctor'
27
+ import { isArchiveFileName } from './lib/scratch'
28
+ import { LEDGER_BASENAME } from './lib/review-ledger'
29
+ import { CLOSE_PROOF_BASENAME, parseCloseProof, deriveBaseState } from './lib/close-proof'
30
+ import { createEvidencePorts } from './lib/evidence-ports' // 아카이브 파일명 판정의 정본은 scratch(leaf)
31
+ // REQ-2026-048 phase-1: 매니페스트 모델·검증과 그 보조 술어는 leaf `lib/evidence.ts`가 정본.
32
+ // 여기서 **재수출**해 기존 import 경로(`from './req-commit'`)를 쓰던 호출부·테스트를 그대로 둔다.
33
+ import {
34
+ archiveBaseName,
35
+ buildArchiveInventory,
36
+ designEvidenceStagePaths,
37
+ durableDesignEvidence,
38
+ isConfinedArchivePath,
39
+ isValidIsoInstant,
40
+ buildManifestEntry,
41
+ expectedArchivePaths,
42
+ manifestHasConsumed,
43
+ serializeManifestLine,
44
+ userConfirmProblem,
45
+ validateManifest,
46
+ designHashFromManifest,
47
+ evidencedPhaseIdsFromManifest,
48
+ verifyCommittedEvidenceIntegrity,
49
+ type ManifestEntry,
50
+ type UserCommitConfirmed,
51
+ } from './lib/evidence'
52
+ export {
53
+ buildArchiveInventory,
54
+ buildManifestEntry,
55
+ designEvidenceStagePaths,
56
+ expectedArchivePaths,
57
+ manifestHasConsumed,
58
+ serializeManifestLine,
59
+ userConfirmProblem,
60
+ validateManifest,
61
+ type ManifestEntry,
62
+ type UserCommitConfirmed,
63
+ type ArchiveInventoryItem,
64
+ } from './lib/evidence'
29
65
  import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type PackageManager, type ResolvedConfig } from './lib/config'
30
66
  import { createGitAdapter, safeSpawnSync, type GitAdapter } from './lib/adapters'
31
67
 
@@ -33,205 +69,28 @@ import { createGitAdapter, safeSpawnSync, type GitAdapter } from './lib/adapters
33
69
  let gitRoot = packageRoot()
34
70
  let pkgManager: PackageManager = DEFAULTS.packageManager
35
71
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
36
- const SHA256_RE = /^[0-9a-f]{64}$/i
37
- const GIT_OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i // git OID: 40(SHA-1) 또는 64(SHA-256)
38
- // ISO 검증은 review-codex의 isValidIsoInstant(형식+달력 유효성) 재사용 달력 불가능 거부(REQ-2026-030).
72
+
73
+ /**
74
+ * repo-상대 경로 파일의 sha256(hex). `lib/evidence`는 fs를 모르는 순수 모듈이라 여기서 주입한다.
75
+ * 🔴 `gitRoot`는 `main()`이 `cfg.root`로 세팅한 뒤에만 유효하다 — designFinalize는 그 이후에만 호출된다.
76
+ */
77
+ function repoRelSha256(repoRel: string): string {
78
+ return createHash('sha256').update(readFileSync(join(gitRoot, ...repoRel.split('/')))).digest('hex')
79
+ }
39
80
  // evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
40
81
  const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
41
82
  const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
42
- /** approvals.jsonl 엔트리 허용 top-level 키(이 외 = 주입/오염 → fail). */
43
- const MANIFEST_KEYS = new Set([
44
- 'kind',
45
- 'phase_id',
46
- 'response_path',
47
- 'response_sha256',
48
- 'review_base_sha',
49
- 'approved_tree',
50
- 'design_hash',
51
- 'approved_at',
52
- 'consumed_at',
53
- 'consumed_by_commit_sha',
54
- 'user_commit_confirmed',
55
- ])
56
-
57
- function escapeRegExp(s: string): string {
58
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
59
- }
83
+
60
84
 
61
85
  // ───────────────────────────────── approvals.jsonl 매니페스트 모델 (B1) ──
62
86
 
63
- /** HIGH 사람확인 감사 기록(암호학적 증명 아님 — D-016-8). */
64
- export interface UserCommitConfirmed {
65
- confirmed: boolean
66
- method: string
67
- confirmed_at: string
68
- note?: string
69
- }
70
87
 
71
- /** approvals.jsonl 한 줄(D-016-3b). kind 격리: phase=approved_tree, design=design_hash. */
72
- export interface ManifestEntry {
73
- kind: ReviewKind
74
- phase_id: string | null
75
- response_path: string
76
- response_sha256: string
77
- review_base_sha: string
78
- approved_tree?: string
79
- design_hash?: string
80
- approved_at: string
81
- consumed_at: string
82
- consumed_by_commit_sha: string
83
- user_commit_confirmed: UserCommitConfirmed | null
84
- }
85
88
 
86
- /**
87
- * 승인 증거(state pin)와 소비 정보로 매니페스트 엔트리 생성(순수, 고정 필드·키 순서).
88
- * kind 격리: phase→approved_tree만, design→design_hash만(반대 kind 필드 미포함).
89
- */
90
- export function buildManifestEntry(
91
- ev: ApprovalEvidence,
92
- consume: { consumedAt: string; consumedByCommitSha: string; userCommitConfirmed: UserCommitConfirmed | null },
93
- ): ManifestEntry {
94
- const base: ManifestEntry = {
95
- kind: ev.review_kind,
96
- phase_id: ev.phase_id ?? null,
97
- response_path: ev.response_path,
98
- response_sha256: ev.response_sha256,
99
- review_base_sha: ev.review_base_sha,
100
- approved_at: ev.approved_at,
101
- consumed_at: consume.consumedAt,
102
- consumed_by_commit_sha: consume.consumedByCommitSha,
103
- user_commit_confirmed: consume.userCommitConfirmed,
104
- }
105
- // fail-fast: kind별 필수 바인딩 필드(phase=approved_tree, design=design_hash). 반대 kind 필드는 미포함.
106
- if (ev.review_kind === 'design') {
107
- const designHash = ev.design_hash
108
- if (typeof designHash !== 'string' || !designHash)
109
- throw new Error('buildManifestEntry: design evidence에 design_hash 누락(fail-fast)')
110
- return { ...base, phase_id: null, design_hash: designHash }
111
- }
112
- const approvedTree = ev.approved_tree
113
- if (typeof approvedTree !== 'string' || !approvedTree)
114
- throw new Error('buildManifestEntry: phase evidence에 approved_tree 누락(fail-fast)')
115
- return { ...base, approved_tree: approvedTree }
116
- }
117
89
 
118
- /** 매니페스트 한 줄 직렬화(JSONL): JSON + 끝 개행. 고정 키 순서라 deterministic. */
119
- export function serializeManifestLine(entry: ManifestEntry): string {
120
- return `${JSON.stringify(entry)}\n`
121
- }
122
90
 
123
- /**
124
- * approvals.jsonl 내용 검증(순수, fail-closed). 문제 목록 반환(빈 배열=정상).
125
- * 검사: malformed JSONL · response_path confinement(현재 티켓 responses/ 직계) · SHA-256 형식 ·
126
- * phase kind의 phase_id 유효성 · (kind,phase_id,sha) 중복/주입.
127
- */
128
- export function validateManifest(
129
- content: string,
130
- opts: { ticketRel: string; validPhaseIds: string[] },
131
- ): string[] {
132
- const problems: string[] = []
133
- const seenKey = new Set<string>()
134
- const seenPath = new Set<string>()
135
- const lines = content.split('\n').map((l) => l.trim()).filter(Boolean)
136
- for (let i = 0; i < lines.length; i++) {
137
- const ln = i + 1
138
- let e: Record<string, unknown>
139
- try {
140
- e = JSON.parse(lines[i] as string) as Record<string, unknown>
141
- } catch {
142
- problems.push(`line ${ln}: malformed JSON`)
143
- continue
144
- }
145
- if (!e || typeof e !== 'object' || Array.isArray(e)) {
146
- problems.push(`line ${ln}: object 아님`)
147
- continue
148
- }
149
- // 예상 외 extra field 금지(주입 차단).
150
- for (const k of Object.keys(e)) if (!MANIFEST_KEYS.has(k)) problems.push(`line ${ln}: 예상 외 필드: ${k}`)
151
- const kind = e.kind
152
- if (kind !== 'phase' && kind !== 'design') problems.push(`line ${ln}: kind 비유효: ${String(kind)}`)
153
- // 공통: 경로 confinement, sha/OID/ISO 형식.
154
- const respPath = typeof e.response_path === 'string' ? e.response_path : ''
155
- if (!isConfinedArchivePath(respPath, opts.ticketRel)) problems.push(`line ${ln}: response_path 비confined: ${respPath}`)
156
- // B1-P2-1: manifest 행(=소비된 승인)의 response_path basename은 그 행의 kind/phase_id **승인본**(-approved)이어야.
157
- // (design→phase 아카이브·타 phase·needs-fix 가리키는 주입/변조 차단. expectedArchivePaths[chore 대상]는 needs-fix 포함 별개.)
158
- if (kind === 'phase' || kind === 'design') {
159
- const expBase = archiveBaseName(kind, kind === 'phase' && typeof e.phase_id === 'string' ? e.phase_id : null)
160
- const name = respPath.split('/').pop() ?? ''
161
- if (!new RegExp(`^${escapeRegExp(expBase)}-r\\d{2,}-approved\\.json$`).test(name))
162
- problems.push(`line ${ln}: response_path가 ${expBase}-rNN-approved.json 아님: ${name}`)
163
- }
164
- if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
165
- if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
166
- if (typeof e.consumed_by_commit_sha !== 'string' || !GIT_OID_RE.test(e.consumed_by_commit_sha)) problems.push(`line ${ln}: consumed_by_commit_sha 비-OID`)
167
- if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
168
- if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
169
- // kind별 strict 바인딩(반대 kind 필드 금지).
170
- if (kind === 'phase') {
171
- if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
172
- problems.push(`line ${ln}: phase_id 비유효: ${String(e.phase_id)}`)
173
- if (typeof e.approved_tree !== 'string' || !GIT_OID_RE.test(e.approved_tree)) problems.push(`line ${ln}: approved_tree 비-OID`)
174
- if ('design_hash' in e) problems.push(`line ${ln}: phase entry에 design_hash 금지`)
175
- } else if (kind === 'design') {
176
- if (e.phase_id !== null) problems.push(`line ${ln}: design entry는 phase_id=null이어야`)
177
- if (typeof e.design_hash !== 'string' || !SHA256_RE.test(e.design_hash)) problems.push(`line ${ln}: design_hash 비-64hex`)
178
- if ('approved_tree' in e) problems.push(`line ${ln}: design entry에 approved_tree 금지`)
179
- }
180
- // user_commit_confirmed: null 또는 유효 감사 기록(confirmed=true·method·ISO confirmed_at)만. (B2-block3)
181
- const ucc = e.user_commit_confirmed
182
- if (ucc !== null) {
183
- const p = userConfirmProblem(ucc)
184
- if (p) problems.push(`line ${ln}: user_commit_confirmed ${p}(null 또는 confirmed=true·method·ISO confirmed_at)`)
185
- }
186
- // 중복: (kind/phase/sha) + response_path 두 기준 모두.
187
- const key = `${String(kind)}:${String(e.phase_id)}:${String(e.response_sha256)}`
188
- if (seenKey.has(key)) problems.push(`line ${ln}: 중복 entry(kind/phase/sha): ${key}`)
189
- seenKey.add(key)
190
- if (respPath) {
191
- if (seenPath.has(respPath)) problems.push(`line ${ln}: 중복 response_path: ${respPath}`)
192
- seenPath.add(respPath)
193
- }
194
- }
195
- return problems
196
- }
197
-
198
- /**
199
- * 이번 target(kind/phaseId)의 **예상 응답 아카이브 repo-경로만** 반환(blanket `git add responses/` 금지).
200
- * archiveNames(responses/ 디렉터리 파일명)에서 해당 base의 rNN 아카이브만 필터 → 현재 티켓 responses/ 경로로.
201
- * needs-fix + approved 모두 포함(evidence chore는 실패 라운드까지 영속화) — 매니페스트 행 검증(approved만)과 별개.
202
- */
203
- export function expectedArchivePaths(
204
- archiveNames: string[],
205
- kind: ReviewKind,
206
- phaseId: string | null,
207
- ticketRel: string,
208
- ): string[] {
209
- const base = archiveBaseName(kind, phaseId)
210
- const re = new RegExp(`^${escapeRegExp(base)}-r(\\d{2,})-(approved|needs-fix)\\.json$`)
211
- const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
212
- // readdir 순서 비의존 — round(rNN) 오름차순 정렬(deterministic).
213
- return archiveNames
214
- .map((n) => ({ n, m: isArchiveFileName(n) ? re.exec(n) : null }))
215
- .filter((x): x is { n: string; m: RegExpExecArray } => x.m !== null)
216
- .sort((a, b) => Number.parseInt(a.m[1] ?? '0', 10) - Number.parseInt(b.m[1] ?? '0', 10))
217
- .map((x) => `${dir}/${x.n}`)
218
- }
219
91
 
220
92
  // ─────────────────────────────────────── B2: HIGH 게이트 / 소비 / preflight(순수) ──
221
93
 
222
- /**
223
- * user_commit_confirmed 감사 기록 형식 검증(순수). 유효하면 null, 아니면 사유.
224
- * 요구: confirmed===true · method(공백 아닌 문자열) · confirmed_at(ISO).
225
- * ⚠️ 위조불가 증명이 아니다 — Claude가 생성 가능한 플래그. 가장 강한 보장 = 사용자가 직접 `req:commit` 실행.
226
- */
227
- export function userConfirmProblem(ucc: unknown): string | null {
228
- if (!ucc || typeof ucc !== 'object') return '기록 없음'
229
- const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
230
- if (c.confirmed !== true) return 'confirmed=true 아님'
231
- if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
232
- if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
233
- return null
234
- }
235
94
 
236
95
  /**
237
96
  * HIGH 사람확인 게이트(D-016-8, 순수). HIGH인데 유효한 `user_commit_confirmed`(confirmed=true·method·ISO confirmed_at)가 없으면 차단.
@@ -332,34 +191,6 @@ export function resolveRecoverySource(
332
191
  return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD가 승인 source 아님 — 복구할 미완 작업 없음' }
333
192
  }
334
193
 
335
- /**
336
- * approvals.jsonl에 **이 evidence가** 이미 finalize된 엔트리가 있는지(순수, 멱등 finalize용).
337
- * ⚠️ B3-R2: consumed_by_commit_sha만으로는 부족 — 같은 source SHA를 쓰는 design-finalize row 등에 오인될 수 있음.
338
- * sourceSha **+ evidence identity(kind·phase_id·response_sha256)** 전부 일치해야 동일 엔트리로 판정.
339
- */
340
- export function manifestHasConsumed(
341
- content: string,
342
- sourceSha: string,
343
- identity: { reviewKind: ReviewKind; phaseId: string | null; responseSha256: string },
344
- ): boolean {
345
- for (const line of content.split('\n').map((l) => l.trim()).filter(Boolean)) {
346
- try {
347
- const e = JSON.parse(line) as { consumed_by_commit_sha?: unknown; kind?: unknown; phase_id?: unknown; response_sha256?: unknown }
348
- if (
349
- e &&
350
- typeof e === 'object' &&
351
- e.consumed_by_commit_sha === sourceSha &&
352
- e.kind === identity.reviewKind &&
353
- (e.phase_id ?? null) === identity.phaseId &&
354
- e.response_sha256 === identity.responseSha256
355
- )
356
- return true
357
- } catch {
358
- // malformed 줄은 무시(무결성은 validateManifest 담당)
359
- }
360
- }
361
- return false
362
- }
363
194
 
364
195
  export interface PreflightInput {
365
196
  existingManifest: string // 현재 approvals.jsonl 내용('' = 없음)
@@ -547,14 +378,125 @@ function stagedNames(): string[] {
547
378
  .filter(Boolean)
548
379
  }
549
380
 
381
+ // 🔴 REQ-2026-052 phase-3b: 매니페스트 순수 파서(parseManifestEntries·evidencedPhaseIdsFromManifest·
382
+ // designHashFromManifest)는 **매니페스트 모델의 정본인 `lib/evidence`(leaf)로 이동**했다. req:new intake
383
+ // 스캔(leaf `lib/intake`)이 req-commit(top-level command)에 의존하지 않고 같은 파서를 쓰기 위함이다.
384
+ // 기존 호출부·테스트 호환을 위해 여기서 re-export한다(경로 무변경).
385
+ export { parseManifestEntries, evidencedPhaseIdsFromManifest, designHashFromManifest } from './lib/evidence'
386
+
387
+ /**
388
+ * 🔴 **dev-complete 발행 결정(순수, DEC-B3)** — 이 phase 커밋이 마지막 phase를 완료시키면 발행할 proof row를,
389
+ * 아니면 null을 낸다. runtime `state.phases`는 **inventory 산출 입력으로만** 쓴다(DEC-B4).
390
+ *
391
+ * @param phaseIds `state.phases`의 phase id들(inventory 원천 — 정렬·중복 제거는 여기서).
392
+ * @param reviewKind 이 finalize의 kind(design이면 null — dev-complete 아님).
393
+ * @param manifestContent 이 phase 엔트리를 **포함한** 매니페스트(발행 전 prospective 검증 대상).
394
+ * @param nowIso 발행 시각.
395
+ * @returns 발행할 dev-complete `CloseProofRow` 또는 null(마지막 phase 아님/미분해/design).
396
+ * @throws design_ref(커밋된 design 승인)가 없으면 — 마지막 phase인데 design 증거가 없다(fail-closed).
397
+ */
398
+ export function computeDevCompleteProof(args: {
399
+ ticketId: string
400
+ phaseIds: readonly string[]
401
+ reviewKind: ReviewKind
402
+ manifestContent: string
403
+ nowIso: string
404
+ }): import('./lib/close-proof').CloseProofRow | null {
405
+ if (args.reviewKind !== 'phase') return null
406
+ const inventory = [...new Set(args.phaseIds)].sort()
407
+ if (inventory.length === 0) return null
408
+ const designRef = designHashFromManifest(args.manifestContent)
409
+ if (!designRef) throw new Error('dev-complete 발행 전 검증 실패: 커밋된 design 승인(design_hash)이 없다')
410
+ // 🔴 DEC-B5: **design-bound** 완전성 — 각 inventory phase가 **현재 design_ref에 결속된** 증거를 가져야 마지막
411
+ // phase다. 단순 phase_id 존재로는 부족(D1 검토분이 D2 완료에 새는 P1). 결속 없는(레거시) 행은 불산입.
412
+ const evidenced = new Set(evidencedPhaseIdsFromManifest(args.manifestContent, designRef))
413
+ if (!inventory.every((id) => evidenced.has(id))) return null // 아직 (design-bound) 마지막 phase 아님(㊱·㊺·㊻)
414
+ return {
415
+ ticket_id: args.ticketId,
416
+ event: 'dev-complete',
417
+ series_id: null,
418
+ resolution: null,
419
+ phase_inventory: inventory,
420
+ design_ref: designRef,
421
+ at: args.nowIso,
422
+ reconstructed: false,
423
+ evidence_basis: null,
424
+ }
425
+ }
426
+
427
+ /**
428
+ * 🔴 REQ-2026-052 phase-3a(DEC-B3): 이 phase 커밋이 **마지막 phase**를 완료시키면 self-verifying dev-complete
429
+ * proof를 발행하고, 같은 커밋에 실을 close-proof 경로를 반환한다. 아니면 `[]`.
430
+ *
431
+ * - **입력**: `state.phases`(inventory 산출 — DEC-B4: **입력으로만**) + `newContent`(이 phase 엔트리 포함 매니페스트).
432
+ * - **prospective 검증(발행 전)**: inventory의 모든 phase가 newContent에 phase 증거로 있고, design_ref가 커밋된
433
+ * design 승인과 일치하는지 확인. 하나라도 어긋나면 발행하지 않는다(마지막 phase가 아니거나 증거 불완전).
434
+ * - **멱등**: close proof가 이미 dev-complete row를 갖고 있으면 append가 duplicate → `[]` 반환(중복 방지)이 아니라
435
+ * **경로는 반환**하되 파일 내용이 그대로라 커밋에 diff가 없다(재시도 안전). 실제 중복 행은 자연키 멱등이 막는다.
436
+ */
437
+ function emitDevCompleteIfLastPhase(ctx: FinalizeCtx, newContent: string): string[] {
438
+ const proof = computeDevCompleteProof({
439
+ ticketId: String(ctx.state.id ?? ''),
440
+ phaseIds: readPhases(ctx.state).map((p) => p.id),
441
+ reviewKind: ctx.ev.review_kind,
442
+ manifestContent: newContent,
443
+ nowIso: new Date().toISOString(),
444
+ })
445
+ if (!proof) return []
446
+ appendCloseProofRowToDisk(ctx.rootForClose, ctx.ticketRel, proof) // 멱등: 이미 있으면 duplicate(no-op)
447
+ return [`${ctx.ticketRel}/responses/${CLOSE_PROOF_BASENAME}`]
448
+ }
449
+
450
+ /**
451
+ * 🔴 발행 후 HEAD-only 재검증(DEC-B3 step 4). HEAD blob만 읽어 dev-complete가 성립하는지 확인 —
452
+ * close proof inventory의 모든 phase evidence + design_ref 일치. 어긋나면 fail-closed.
453
+ */
454
+ function verifyDevCompleteAtHead(ctx: FinalizeCtx): void {
455
+ const cpRel = `${ctx.ticketRel}/responses/${CLOSE_PROOF_BASENAME}`
456
+ const mfRel = `${ctx.ticketRel}/responses/approvals.jsonl`
457
+ const cpText = headBlobText(cpRel)
458
+ const mfText = headBlobText(mfRel)
459
+ if (cpText === null || mfText === null) throw new Error('dev-complete HEAD 재검증 실패: close proof·매니페스트가 HEAD에 없다')
460
+ const parsed = parseCloseProof(cpText)
461
+ if (parsed.problems.length) throw new Error(`dev-complete HEAD 재검증 실패: close proof 손상 — ${parsed.problems.join('; ')}`)
462
+ // 🔴 DEC-B6·B7(phase-3b2·3b3): intake와 **동일한** committed 증거(design+phase) 무결성 규칙(공유
463
+ // verifyCommittedEvidenceIntegrity). 발행 후에도 design 승인 archive+inventory·모든 phase archive가 HEAD에
464
+ // 존재하고 SHA가 일치해야 한다 — 아니면 fail-closed(intake의 corrupt와 대칭).
465
+ const integrity = verifyCommittedEvidenceIntegrity({ ticketRel: ctx.ticketRel, manifestText: mfText, ports: createEvidencePorts(gitRoot, `${ctx.ticketRel}/responses`) })
466
+ if (integrity.problems.length)
467
+ throw new Error(`dev-complete HEAD 재검증 실패: committed 증거 손상 — ${integrity.problems.join('; ')}`)
468
+ const committedDesignRef = designHashFromManifest(mfText)
469
+ const state = deriveBaseState({
470
+ durabilityRequired: true,
471
+ closeProofRows: parsed.rows,
472
+ ledgerHasApprovedClose: false,
473
+ committedEvidenceComplete: true,
474
+ // 🔴 DEC-B5: HEAD 재검증도 design-bound — 현재 committed design_ref에 결속된 phase evidence만 산입.
475
+ evidencedPhaseIds: evidencedPhaseIdsFromManifest(mfText, committedDesignRef),
476
+ committedDesignRef,
477
+ })
478
+ if (state !== 'dev-complete')
479
+ throw new Error(`dev-complete HEAD 재검증 실패: 발행 후 파생 상태가 dev-complete가 아니다(${state})`)
480
+ }
481
+
482
+ /** `HEAD:<repoRel>` blob 텍스트(없으면 null). */
483
+ function headBlobText(repoRel: string): string | null {
484
+ try {
485
+ return git(['show', `HEAD:${repoRel}`])
486
+ } catch {
487
+ return null
488
+ }
489
+ }
490
+
550
491
  interface FinalizeCtx {
551
492
  ticketDir: string
552
493
  ticketRel: string
553
494
  responsesDir: string
554
495
  manifestPath: string
496
+ /** REQ-2026-052: close proof append용 repo root(디스크 경로 해소). */
497
+ rootForClose: string
555
498
  state: WorkflowState
556
499
  ev: ApprovalEvidence
557
- existing: string
558
500
  archiveNames: string[]
559
501
  validPhaseIds: string[]
560
502
  sourceSha: string
@@ -565,13 +507,28 @@ interface FinalizeCtx {
565
507
  * 이미 sourceSha가 매니페스트에 있으면(=evidence 커밋은 됐고 consume만 못 함) append/chore를 skip하고 소비만 수행.
566
508
  * 소비는 항상 마지막. state.json은 scratch 유지(커밋 안 함).
567
509
  */
568
- function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
569
- // B3-R2: skip(소비-only) 경로 포함 — 변조된 매니페스트 위에서 consume 금지. 기존 무결성 먼저(복구 모드엔 preflight가 없으므로 필수).
570
- if (ctx.existing.trim()) {
571
- const ep = validateManifest(ctx.existing, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
572
- if (ep.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${ep.join('; ')}`)
510
+ /** 🔴 테스트 전용: git 경계·gitRoot를 실제 저장소로 주입한다(finalizeEvidenceAndConsume 격리 테스트용). */
511
+ export function __setGitForTest(root: string): void {
512
+ gitRoot = root
513
+ gitAdapter = createGitAdapter(root)
514
+ }
515
+
516
+ export function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
517
+ // 🔴 REQ-2026-052 phase-3a P1(리뷰 반영): finalize 멱등성을 **HEAD 기준**으로 판정한다 — 워킹 매니페스트가
518
+ // 아니라. 이전엔 `ctx.existing`(워킹트리 파일)을 base·멱등 판정에 썼는데, evidence commit이 실패하면
519
+ // 디스크엔 이미 매니페스트가 쓰였으므로 재시도가 그걸 "이미 finalize됨"으로 오판해 **HEAD에 증거가 없는데
520
+ // 완료로 진행**했다(dev-complete proof·archive·ledger 미커밋). HEAD blob만 base로 삼아 "커밋 성공 여부"가
521
+ // 아니라 "HEAD에 실제 존재하는지"로 판정한다.
522
+ const manifestRel = `${ctx.ticketRel}/responses/approvals.jsonl`
523
+ // 🔴 `git show`(gitAdapter)가 후행 개행을 제거하므로 복원한다 — 없으면 append 시 두 JSON이 한 줄로 붙어 손상된다.
524
+ const headManifestRaw = headBlobText(manifestRel) ?? ''
525
+ const headManifest = headManifestRaw && !headManifestRaw.endsWith('\n') ? `${headManifestRaw}\n` : headManifestRaw
526
+ // 기존 무결성 먼저(변조된 매니페스트 위에서 consume 금지). HEAD blob 검증.
527
+ if (headManifest.trim()) {
528
+ const ep = validateManifest(headManifest, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
529
+ if (ep.length) throw new Error(`HEAD approvals.jsonl 무결성 실패(fail-closed): ${ep.join('; ')}`)
573
530
  }
574
- const already = manifestHasConsumed(ctx.existing, ctx.sourceSha, {
531
+ const already = manifestHasConsumed(headManifest, ctx.sourceSha, {
575
532
  reviewKind: ctx.ev.review_kind,
576
533
  phaseId: ctx.ev.phase_id ?? null,
577
534
  responseSha256: ctx.ev.response_sha256,
@@ -582,17 +539,27 @@ function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
582
539
  consumedByCommitSha: ctx.sourceSha,
583
540
  userCommitConfirmed: (ctx.state.user_commit_confirmed as UserCommitConfirmed | null) ?? null,
584
541
  })
585
- const newContent = ctx.existing + serializeManifestLine(entry)
542
+ // 🔴 base는 **HEAD 매니페스트** — 실패한 이전 쓰기가 남긴 디스크 엔트리를 이어붙여 중복시키지 않는다.
543
+ const newContent = headManifest + serializeManifestLine(entry)
586
544
  const reproblems = validateManifest(newContent, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
587
545
  if (reproblems.length)
588
546
  throw new Error(`(예상외) 매니페스트 검증 실패: ${reproblems.join('; ')} — source=${ctx.sourceSha} 커밋됨, --finalize로 복구`)
589
547
  mkdirSync(ctx.responsesDir, { recursive: true })
590
548
  writeFileSync(ctx.manifestPath, newContent, 'utf8')
591
549
  const archivePaths = expectedArchivePaths(ctx.archiveNames, ctx.ev.review_kind, ctx.ev.phase_id ?? null, ctx.ticketRel)
592
- git(['add', ...archivePaths, `${ctx.ticketRel}/responses/approvals.jsonl`])
550
+ // REQ-2026-051 D7: 리뷰 원장이 있으면 phase 증거 커밋에 함께 싣는다. 없으면 넣지 않는다 —
551
+ // 없는 pathspec으로 `git add`가 실패해 증거 커밋 전체가 무산되면 본말전도다(design 경로의 ledgerExists와 대칭).
552
+ const ledgerRel = `${ctx.ticketRel}/responses/${LEDGER_BASENAME}`
553
+ const ledgerAdd = existsSync(join(ctx.ticketDir, 'responses', LEDGER_BASENAME)) ? [ledgerRel] : []
554
+ // 🔴 REQ-2026-052 phase-3a: 이 커밋이 **마지막 phase**를 완료시키면 self-verifying dev-complete proof를
555
+ // **같은 durable 커밋**에 발행한다(DEC-B3). 아니면 발행 안 함. newContent(이 phase 엔트리 포함)로 판정.
556
+ const devCompleteAdd = emitDevCompleteIfLastPhase(ctx, newContent)
557
+ git(['add', ...archivePaths, `${ctx.ticketRel}/responses/approvals.jsonl`, ...ledgerAdd, ...devCompleteAdd])
593
558
  const choreLeak = stagedNames().filter((p) => !p.startsWith(`${ctx.ticketRel}/responses/`))
594
559
  if (choreLeak.length) throw new Error(`evidence 커밋에 responses 외 staged 금지(코드/state 누수): ${choreLeak.join(', ')}`)
595
- git(['commit', '-m', `chore(${ctx.state.id}): evidence-finalize — ${ctx.ev.review_kind} ${ctx.ev.phase_id ?? ''} 아카이브·approvals.jsonl`])
560
+ git(['commit', '-m', `chore(${ctx.state.id}): evidence-finalize — ${ctx.ev.review_kind} ${ctx.ev.phase_id ?? ''} 아카이브·approvals.jsonl${devCompleteAdd.length ? '·dev-complete' : ''}`])
561
+ // 🔴 발행 후 HEAD-only 재검증(DEC-B3 step 4): 발행했으면 HEAD blob만으로 dev-complete가 성립해야 한다.
562
+ if (devCompleteAdd.length) verifyDevCompleteAtHead(ctx)
596
563
  } else {
597
564
  console.log('[req:commit] evidence 이미 finalize됨(멱등 skip) — 소비만 수행')
598
565
  }
@@ -617,33 +584,25 @@ function designFinalize(args: {
617
584
  if (!dev) throw new Error('design_approval_evidence 없음 — design 승인 후 실행')
618
585
  if (dev.review_kind !== 'design') throw new Error(`design_approval_evidence.review_kind != design: ${String(dev.review_kind)}`)
619
586
  runDoctor(args.doctorArgs) // design-finalize도 doctor 우회 금지(정상)
620
- const opts = { ticketRel: args.ticketRel, validPhaseIds: args.validPhaseIds }
621
- const existing = existsSync(args.manifestPath) ? readFileSync(args.manifestPath, 'utf8') : ''
622
- // B3-P2: 기존 매니페스트 단독 무결성 먼저(중복+다른 오염 시 묵인 금지 — 오염이면 여기서 fail-closed).
623
- if (existing.trim()) {
624
- const existingProblems = validateManifest(existing, opts)
625
- if (existingProblems.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${existingProblems.join('; ')}`)
626
- }
627
- const headSha = git(['rev-parse', 'HEAD'])
628
- const entry = buildManifestEntry(dev, { consumedAt: new Date().toISOString(), consumedByCommitSha: headSha, userCommitConfirmed: null })
629
- const newContent = existing + serializeManifestLine(entry)
630
- const problems = validateManifest(newContent, opts)
631
- // 기존이 이미 클린이므로 newContent의 문제는 후보(candidate) 때문. **오직 중복뿐**일 때만 멱등 skip, 하나라도 다른 문제면 fail.
632
- if (problems.length) {
633
- if (problems.every((p) => p.includes('중복'))) {
634
- console.log('[req:commit] design 승인 이미 기록됨(멱등 skip)')
635
- return
636
- }
637
- throw new Error(`design-finalize 매니페스트 검증 실패: ${problems.join('; ')}`)
587
+ // REQ-2026-048 phase-3: 실제 내구화는 **공유 구현**에 위임한다. 정상 승인 경로(review-codex)와
588
+ // 복구 경로가 같은 함수를 부르므로 동작이 갈라질 수 없다(DEC-1·DEC-3).
589
+ const r = durableDesignEvidence({
590
+ ticketId: String(args.state.id ?? ''),
591
+ ticketRel: args.ticketRel,
592
+ evidence: dev,
593
+ validPhaseIds: args.validPhaseIds,
594
+ nowIso: new Date().toISOString(),
595
+ ports: createEvidencePorts(gitRoot, `${args.ticketRel}/responses`),
596
+ })
597
+ if (r.outcome === 'already-durable') {
598
+ console.log('[req:commit] design 승인 이미 내구화됨(HEAD 기준 멱등 skip)')
599
+ return
638
600
  }
639
- const responsePath = typeof dev.response_path === 'string' ? dev.response_path : ''
640
- mkdirSync(args.responsesDir, { recursive: true })
641
- writeFileSync(args.manifestPath, newContent, 'utf8')
642
- git(['add', responsePath, `${args.ticketRel}/responses/approvals.jsonl`])
643
- const leak = stagedNames().filter((p) => !p.startsWith(`${args.ticketRel}/responses/`))
644
- if (leak.length) throw new Error(`design-finalize 커밋에 responses 외 staged 금지: ${leak.join(', ')}`)
645
- git(['commit', '-m', `chore(${args.state.id}): design-finalize — design 승인 approvals.jsonl 기록`])
646
- console.log('[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록')
601
+ console.log(
602
+ r.outcome === 'recommitted'
603
+ ? '[req:commit] ✅ design-finalize 복구 완료 — 매니페스트는 이미 있었고 커밋만 누락돼 재커밋했습니다'
604
+ : '[req:commit] design-finalize 완료 — approvals.jsonl 기록',
605
+ )
647
606
  }
648
607
 
649
608
  export function main(argv: string[] = process.argv.slice(2)): void {
@@ -742,7 +701,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
742
701
  runDoctor([...doctorArgs, '--finalize'])
743
702
  const gate = userConfirmGate(fstate)
744
703
  if (gate.blocked) throw new Error(gate.reason)
745
- finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state: fstate, ev, existing, archiveNames, validPhaseIds, sourceSha })
704
+ finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state: fstate, ev, archiveNames, validPhaseIds, sourceSha, rootForClose: cfg.root })
746
705
  console.log(`[req:commit] ✅ finalize 복구 완료 — source=${sourceSha.slice(0, 8)} · evidence/consume 복구`)
747
706
  return
748
707
  }
@@ -788,7 +747,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
788
747
  // 4b) B3 복구 마커 — source 커밋됨, evidence 미완. 이후 중단 시 `req:commit <id> --finalize --run`으로 복구.
789
748
  writeState(ticketDir, markPendingEvidence(state, sourceSha))
790
749
  // 5) evidence-finalize(멱등) + 소비(마지막).
791
- finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state, ev, existing, archiveNames, validPhaseIds, sourceSha })
750
+ finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state, ev, archiveNames, validPhaseIds, sourceSha, rootForClose: cfg.root })
792
751
  console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
793
752
  }
794
753