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.
@@ -32,12 +32,27 @@ import { pathToFileURL } from 'node:url'
32
32
  import { createHash } from 'node:crypto'
33
33
  import Ajv from 'ajv'
34
34
  import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type ResolvedConfig, type PackageManager, type ReviewBudget } from './lib/config'
35
+ // REQ-2026-048 phase-1: 증거/매니페스트 공통 술어는 leaf `lib/evidence.ts`가 정본. 여기서 **재수출**해
36
+ // 기존 import 경로(`from './review-codex'`)를 쓰던 호출부·테스트를 그대로 둔다.
37
+ import { archiveBaseName, durableDesignEvidence, isValidIsoInstant } from './lib/evidence'
38
+ export { archiveBaseName, isValidIsoInstant } from './lib/evidence'
39
+ import { createEvidencePorts } from './lib/evidence-ports'
35
40
  import {
36
41
  createGitAdapter,
37
42
  createCodexReviewerAdapter,
43
+ ReviewCallError,
38
44
  type GitAdapter,
39
45
  type ReviewerAdapter,
40
46
  } from './lib/adapters'
47
+ import {
48
+ ledgerPath,
49
+ appendLedgerRow,
50
+ serializeLedgerRow,
51
+ type LedgerRow,
52
+ } from './lib/review-ledger'
53
+ import { computeReviewSemanticIdentity } from './lib/review-target'
54
+ import { closeProofPath, appendCloseProofRow, type CloseProofRow } from './lib/close-proof'
55
+ import { summarizeLockfileDiff } from './lib/lockfile-diff'
41
56
  import { parseStatusZ, entryPaths, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
42
57
  import { isArchiveFileName, isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
43
58
 
@@ -527,6 +542,12 @@ export interface ApprovalEvidence {
527
542
  review_base_sha: string
528
543
  approved_tree?: string
529
544
  design_hash?: string | null
545
+ /**
546
+ * 🔴 REQ-2026-052 DEC-B5(phase-3a2): **phase 전용** — 이 phase 승인 시점의 committed design 결속
547
+ * (= `designValid` 통과값 `currentHash`). evidence-finalize가 manifest phase 행 `phase_design_ref`로 기록한다.
548
+ * design kind·레거시(phaseId 없음)면 null.
549
+ */
550
+ phase_design_ref?: string | null
530
551
  codex_thread_id: string
531
552
  machine_schema_version: string
532
553
  status: string
@@ -579,6 +600,16 @@ export interface ReviewCallLogRow {
579
600
  */
580
601
  review_model: string | null
581
602
  review_reasoning_effort: string | null
603
+ /**
604
+ * REQ-2026-045(phase-2-observability): 재리뷰 장기화 원인분석용 관측성 지원 필드.
605
+ * 전부 **개수/해시만**(내용배제 유지). 진단·재구성 보조이며 **승인 증거가 아니다**(로그는 측정 전용·gitignore·fail-closed).
606
+ */
607
+ prompt_bytes: number
608
+ review_duration_ms: number
609
+ previous_findings_count: number
610
+ assembled_prompt_sha256: string
611
+ review_base_sha: string | null
612
+ review_tree: string | null
582
613
  }
583
614
 
584
615
  /**
@@ -600,6 +631,13 @@ export function buildReviewCallLogRow(args: {
600
631
  policyVersion: string
601
632
  reviewModel: string | null
602
633
  reviewReasoningEffort: string | null
634
+ // REQ-2026-045: 관측성 지원 필드(개수/해시). 값은 호출부가 계산해 주입(순수성 유지).
635
+ promptBytes: number
636
+ reviewDurationMs: number
637
+ previousFindingsCount: number
638
+ assembledPromptSha256: string
639
+ reviewBaseSha: string | null
640
+ reviewTree: string | null
603
641
  }): ReviewCallLogRow {
604
642
  return {
605
643
  ticket_id: args.ticketId,
@@ -613,9 +651,23 @@ export function buildReviewCallLogRow(args: {
613
651
  policy_version: args.policyVersion,
614
652
  review_model: args.reviewModel,
615
653
  review_reasoning_effort: args.reviewReasoningEffort,
654
+ prompt_bytes: args.promptBytes,
655
+ review_duration_ms: args.reviewDurationMs,
656
+ previous_findings_count: args.previousFindingsCount,
657
+ assembled_prompt_sha256: args.assembledPromptSha256,
658
+ review_base_sha: args.reviewBaseSha,
659
+ review_tree: args.reviewTree,
616
660
  }
617
661
  }
618
662
 
663
+ /**
664
+ * REQ-2026-045: 조립 프롬프트의 **UTF-8 바이트** 수(내용 아님). JS `.length`(UTF-16 code unit)는 비-ASCII에서
665
+ * 바이트 수와 다르므로(`'가'.length===1`이지만 3바이트) `Buffer.byteLength(…,'utf8')`로 계산한다.
666
+ */
667
+ export function assembledPromptBytes(prompt: string): number {
668
+ return Buffer.byteLength(prompt, 'utf8')
669
+ }
670
+
619
671
  /**
620
672
  * 로그 append(측정 전용). **실패를 삼킨다**(R8).
621
673
  *
@@ -642,8 +694,12 @@ export interface ProcessResponseResult {
642
694
  export interface BlockedReviewTarget {
643
695
  review_kind: ReviewKind
644
696
  phase_id: string | null
645
- review_base_sha: string
646
- review_binding: string
697
+ /**
698
+ * 🔴 REQ-2026-052: **semantic identity**로 키잉한다(review-target.ts). 예전엔 `review_base_sha`+`review_binding`
699
+ * (reviewTree/designHash)이었으나, pre-call 원장 커밋이 매 라운드 그 값을 흔들어 무한 재리뷰 차단이 깨졌다.
700
+ * semantic identity는 `responses/` audit 변화에 불변이라 "같은 블록 리뷰 반복"을 정확히 감지한다.
701
+ */
702
+ semantic_identity: string
647
703
  }
648
704
 
649
705
  export interface BlockedReviewMarker extends BlockedReviewTarget {
@@ -783,6 +839,19 @@ export function buildPreviousFindingsBlock(state: WorkflowState, kind: ReviewKin
783
839
  ].join('\n')
784
840
  }
785
841
 
842
+ /**
843
+ * REQ-2026-045: previousFindingsToClose로 전달되는 직전 same-target NEEDS_FIX finding 총수(측정 지원, 개수만).
844
+ * buildPreviousFindingsBlock과 동일 가드(교차-대상·승인 후 리셋)를 써서 프롬프트 블록과 정합한다. 값 = 스냅샷 shown + elided.
845
+ */
846
+ export function previousFindingsCount(state: WorkflowState, kind: ReviewKind, phaseId: string | null): number {
847
+ const lr = state.last_review as LastReviewMarker | undefined
848
+ if (!lr || lr.outcome !== 'needs-fix') return 0
849
+ if (lr.review_kind !== kind || lr.phase_id !== phaseId) return 0
850
+ const snap = validatePersistedSnapshot(lr.findings, lr.elided_count)
851
+ if (!snap) return 0
852
+ return snap.findings.length + snap.elided_count
853
+ }
854
+
786
855
  function sameLastReviewTarget(a: LastReviewMarker | undefined, kind: ReviewKind, phaseId: string | null, compareHash: string | null): boolean {
787
856
  return !!a && a.review_kind === kind && a.phase_id === phaseId && a.compare_hash === compareHash
788
857
  }
@@ -866,6 +935,21 @@ export interface SeriesRecord {
866
935
  closed_reason: 'approved' | 'human-resolution' | null
867
936
  // closed_reason='human-resolution'일 때만. 사람이 escalate된 series를 종료·대체로 결정한 손기록.
868
937
  human_resolution?: HumanResolution
938
+ // 🔴 REQ-2026-054(DEC-C3): pre-dispatch 실패로 **환불된** 회차 수(additive·기본 0). 예산 게이트가 보는 유효
939
+ // 회차 = attempts - refunded_attempts. attempts는 단조 유지(원장 자연키 충돌 회피)하고 여기서만 환불한다.
940
+ refunded_attempts?: number
941
+ }
942
+
943
+ /**
944
+ * 리뷰 호출 실패의 lifecycle 분류(REQ-2026-054·DEC-C2, 순수). 도달한 가장 먼 단계로 분류한다.
945
+ * 🔴 **오직 타입된 pre-dispatch만 환불 대상**(pre_dispatch_failed). 일반 오류·확인 전 dispatched는
946
+ * dispatched_unknown으로 **차감**(fail-closed — "명백한 pre-dispatch만 무차감").
947
+ */
948
+ export type DispatchFailureLifecycle = 'pre_dispatch_failed' | 'dispatched_unknown' | 'dispatch_confirmed'
949
+ export function classifyDispatchFailure(err: unknown, dispatchConfirmed: boolean): DispatchFailureLifecycle {
950
+ if (err instanceof ReviewCallError && err.dispatchPhase === 'pre-dispatch') return 'pre_dispatch_failed'
951
+ if (dispatchConfirmed) return 'dispatch_confirmed' // thread_id 확보 후 실패 = dispatched 확실.
952
+ return 'dispatched_unknown' // 확인 전 dispatched·타입 없는 일반 오류(fail-closed 차감).
869
953
  }
870
954
 
871
955
  /** 사람의 series 종결 결정(REQ-2026-029 A-2b D1). accept-risk 없음(배분표 ④ — decision은 둘뿐). */
@@ -995,6 +1079,13 @@ export interface SuccessorOf {
995
1079
  parent_attempts_total: number // 부모 **모든** series attempts 합
996
1080
  parent_replace_resolution: HumanResolution // 부모의 replace 종결 손기록 그대로
997
1081
  recorded_at: string // 자식 생성 시각(부모 값 아님)
1082
+ /**
1083
+ * 🔴 REQ-2026-052 phase-4(DEC-D2): 부모의 **replace 종결된 series_id**. 이 committed 값이 부모 티켓의
1084
+ * `series-terminal` close-proof 행을 **완전히 결정**하게 해 `req:reconstruct`가 소비할 수 있게 한다
1085
+ * (없으면 series_id 미결정 → 복원 불가). 구식 successor_of(이 필드 없음)는 backward-compat로 허용하되
1086
+ * reconstruct 대상이 아니다. **선택 필드**(기존 successor 티켓 무회귀).
1087
+ */
1088
+ parent_series_id?: string
998
1089
  }
999
1090
 
1000
1091
  /**
@@ -1016,6 +1107,8 @@ export function resolveSuccessorLineage(parentState: WorkflowState, parentReqId:
1016
1107
  parent_attempts_total: parentAttemptsTotal,
1017
1108
  parent_replace_resolution: replace.human_resolution,
1018
1109
  recorded_at: recordedAt,
1110
+ // 🔴 phase-4(DEC-D2): replace 종결된 series_id를 lineage에 박아, 부모 series-terminal 행을 reconstruct가 완전 결정하게 한다.
1111
+ parent_series_id: replace.series_id,
1019
1112
  }
1020
1113
  }
1021
1114
 
@@ -1033,7 +1126,24 @@ export type BudgetDecision =
1033
1126
  */
1034
1127
  export function openSeriesAttempts(state: WorkflowState, kind: ReviewKind, phaseId: string | null): number {
1035
1128
  const rec = openSeriesRecord(state, kind, phaseId)
1036
- return rec ? rec.attempts : 0
1129
+ // 🔴 REQ-2026-054(DEC-C3): 유효 회차 = attempts - refunded_attempts. pre-dispatch 실패로 환불된 회차는
1130
+ // 예산에서 뺀다. refunded_attempts 부재(옛 state)면 attempts 그대로(하위호환).
1131
+ return rec ? Math.max(0, rec.attempts - (rec.refunded_attempts ?? 0)) : 0
1132
+ }
1133
+
1134
+ /**
1135
+ * pre-dispatch 실패 회차 환불(REQ-2026-054·DEC-C3, 순수). 열린 series의 `refunded_attempts +1`.
1136
+ * 🔴 `attempts`는 건드리지 않는다 — 감소하면 재시도가 같은 `(series_id, attempt)`를 만들어 원장 자연키가
1137
+ * 충돌(conflict)한다. 유효 회차는 `openSeriesAttempts`가 차감으로 낸다. 열린 series 없으면 no-op(방어).
1138
+ */
1139
+ export function refundAttempt(state: WorkflowState, kind: ReviewKind, phaseId: string | null): WorkflowState {
1140
+ const series = readSeries(state)
1141
+ const openIdx = series.findIndex(
1142
+ (r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId && r.closed_reason === null,
1143
+ )
1144
+ if (openIdx < 0) return state
1145
+ const next = series.map((r, i) => (i === openIdx ? { ...r, refunded_attempts: (r.refunded_attempts ?? 0) + 1 } : r))
1146
+ return { ...state, review_series: next }
1037
1147
  }
1038
1148
 
1039
1149
  /**
@@ -1049,22 +1159,8 @@ export function checkReviewBudget(openAttempts: number, budget: ReviewBudget): B
1049
1159
  return { kind: 'hard-blocked', attempt }
1050
1160
  }
1051
1161
 
1052
- /** ISO instant 형식(밀리초 선택). req-commit.ts ISO_RE와 같은 패턴(손기록 계약 통일). */
1053
- const REVIEW_ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/
1054
-
1055
- /**
1056
- * ISO instant 유효성(REQ-2026-028 D2). **형식 + 달력 유효성 둘 다**(design-r02·r03).
1057
- * `ISO_RE`만으론 `2026-99-99T99:99:99Z`가 통과하므로, 재파싱해 성분(연·월·일·시·분·초)이 보존되는지 확인.
1058
- * 밀리초 표기 차(`08Z` vs `08.000Z`)는 비교에서 무시 — 성분이 맞으면 유효.
1059
- */
1060
- export function isValidIsoInstant(s: unknown): boolean {
1061
- if (typeof s !== 'string' || !REVIEW_ISO_RE.test(s)) return false
1062
- const d = new Date(s)
1063
- if (Number.isNaN(d.getTime())) return false
1064
- // 재직렬화 후 초까지 성분 비교(밀리초 절단). `2026-99-99...`는 여기서 불일치로 걸린다.
1065
- const canon = (x: string): string => x.replace(/\.\d+Z$/, 'Z').replace(/Z$/, '')
1066
- return canon(d.toISOString()) === canon(s)
1067
- }
1162
+ // `isValidIsoInstant`는 REQ-2026-048 phase-1에서 `lib/evidence.ts`로 이동했다(매니페스트 검증과 공유하는
1163
+ // 술어라 leaf에 있어야 review-codex↔req-commit 순환이 생기지 않는다). 기존 import 경로 보존을 위해 re-export한다.
1068
1164
 
1069
1165
  /** 사람 예외 손기록(REQ-2026-028 D2). `user_commit_confirmed`와 같은 모양. */
1070
1166
  export interface ReviewExceptionConfirmed {
@@ -1112,10 +1208,156 @@ export function consumeReviewException(
1112
1208
  * **예산 게이트가 `recordAttempt` 전이다**(REQ-2026-028 R5): 막을 거면 호출도 기록도 하기 전에 막는다.
1113
1209
  * throw 시 state는 바뀌지 않는다(예외 소비 성공 시에만 쓰기). **반환 `state`가 후처리의 유일한 base다**(R9).
1114
1210
  */
1115
- export function withAttemptRecorded<T>(
1116
- ctx: { ticketDir: string; state: WorkflowState; kind: ReviewKind; phaseId: string | null; budget: ReviewBudget },
1117
- call: () => T,
1118
- ): { result: T; state: WorkflowState } {
1211
+ /**
1212
+ * 원장 1행 append(REQ-2026-051 D5·D6). 티켓 `responses/review-ledger.jsonl`에 쓴다.
1213
+ *
1214
+ * 🔴 **두 실패를 분리한다**(phase-2 리뷰 P1 — D5와 D6이 뭉개지면 안 된다):
1215
+ *
1216
+ * 1. **기존 원장 읽기·파싱·검증 실패 = 전파(fail-closed, D5).** 기존 본문이 파싱 불가(잘린 JSONL)이거나
1217
+ * 같은 자연키에 다른 내용이 오면 `appendLedgerRow`가 `conflict`를 낸다. `readFileSync` 자체의 오류도
1218
+ * 마찬가지다. 이것들은 **감사 원장의 무결성 손상**이므로 조용히 진행하면 D5 위반이다 — throw한다.
1219
+ * 호출자(attempt-opened)가 외부 호출 **전**에 이걸 부르므로, 손상된 원장은 리뷰를 시작조차 못 한다
1220
+ * (D10 pre-review clean-tree 게이트와 같은 자리·같은 태도).
1221
+ *
1222
+ * 2. **새 행 쓰기 실패 = 삼킴(D6).** 읽기·검증이 통과했는데 mkdir/write가 실패하는 것은 순수한 I/O
1223
+ * 문제다. 이것이 승인·차단 판정이나 exit code를 뒤집으면 계약 위반이다(측정 로그 R8과 같은 취지).
1224
+ * 경고만 내고 판정은 그대로 둔다.
1225
+ *
1226
+ * 정상 경로에서 attempt-opened가 무결성을 확인하고 우리가 유효한 행 하나만 append하므로, attempt-closed
1227
+ * 시점의 재검증은 (협조적 단일 worktree 전제에서) 통과한다 — closed에서 전파가 실제로 발화하는 것은
1228
+ * "일어나선 안 될" 손상뿐이고, 그때는 크게 실패하는 편이 옳다.
1229
+ */
1230
+ export function appendLedgerRowToDisk(root: string, ticketRel: string, row: LedgerRow): void {
1231
+ const abs = join(root, ledgerPath(ticketRel))
1232
+ // ── 읽기·검증 단계(D5 — 전파) ──
1233
+ const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : '' // readFileSync 오류는 전파(무결성 신호)
1234
+ const r = appendLedgerRow(existing, row)
1235
+ if (r.outcome === 'conflict')
1236
+ throw new Error(`리뷰 원장 무결성 실패(fail-closed): ${r.problems.join('; ')}`)
1237
+ if (r.outcome === 'duplicate') return // 재실행 멱등(no-op)
1238
+ // ── 쓰기 단계(D6 — 삼킴) ──
1239
+ try {
1240
+ mkdirSync(dirname(abs), { recursive: true })
1241
+ writeFileSync(abs, r.content, 'utf8')
1242
+ } catch (err) {
1243
+ console.warn(`[req:review-codex] ⚠️ 리뷰 원장 쓰기 실패(판정에는 영향 없음): ${err instanceof Error ? err.message : String(err)}`)
1244
+ }
1245
+ }
1246
+
1247
+ /**
1248
+ * 🔴 **pre-call ledger-only 커밋**(REQ-2026-052 DEC-A4·A6 step 4). `attempt-opened`를 외부 호출 **전에**
1249
+ * HEAD에 durable하게 만든다 — process가 죽어도 "예산 사용·미확정 호출"이 HEAD에서 관측된다(요구 #1).
1250
+ *
1251
+ * 🔴 **pathspec 커밋 — 원장 경로만**: `git commit -- <ledger>`는 워킹트리의 원장만 커밋하고, 인덱스에
1252
+ * staged된 design 문서·phase 코드는 **건드리지 않는다**(그대로 staged 유지). 그래서 리뷰 대상 binding
1253
+ * (reviewTree)이 커밋 후에도 보존된다. `git add -- <ledger>`로 먼저 스테이징(1라운드 untracked 대응).
1254
+ *
1255
+ * 🔴 **fail-closed**: 커밋 실패(훅 등)면 throw — opened가 durable하지 않은 채 외부 호출하면 요구 위반이다.
1256
+ * 실패는 **외부 호출 전**에 전파된다(호출은 아직 안 일어났다).
1257
+ */
1258
+ /**
1259
+ * close proof 1행 append(REQ-2026-052). 원장의 `appendLedgerRowToDisk`와 같은 규칙:
1260
+ * 읽기·검증 손상은 **전파**(fail-closed·D5), 쓰기 실패는 **삼킴**(D6). 멱등(duplicate=no-op).
1261
+ */
1262
+ export function appendCloseProofRowToDisk(root: string, ticketRel: string, row: CloseProofRow): void {
1263
+ const abs = join(root, closeProofPath(ticketRel))
1264
+ const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
1265
+ const r = appendCloseProofRow(existing, row)
1266
+ if (r.outcome === 'conflict') throw new Error(`close proof 무결성 실패(fail-closed): ${r.problems.join('; ')}`)
1267
+ if (r.outcome === 'duplicate') return
1268
+ try {
1269
+ mkdirSync(dirname(abs), { recursive: true })
1270
+ writeFileSync(abs, r.content, 'utf8')
1271
+ } catch (err) {
1272
+ console.warn(`[req] ⚠️ close proof 쓰기 실패(판정에는 영향 없음): ${err instanceof Error ? err.message : String(err)}`)
1273
+ }
1274
+ }
1275
+
1276
+ /**
1277
+ * 🔴 REQ-2026-052: 부모 티켓의 replace/human-resolution 종결을 **durable화**한다(요구 #3·test #3).
1278
+ *
1279
+ * `req:new --successor-of`가 부모의 replace 종결을 확인한 직후 호출된다. 부모 state(scratch)의
1280
+ * `human-resolution` closed series들에 대해 `series-terminal` close proof를 append하고, close proof·ledger를
1281
+ * **pathspec 커밋**한다(다른 staged 항목 미접촉). 멱등이라 재실행에 중복 없음.
1282
+ *
1283
+ * decision 매핑: `replace`→resolution `replace`, `terminate`→resolution `human-resolution`.
1284
+ * @returns 커밋했으면 true(방출·커밋 발생), 이미 durable/대상 없음이면 false.
1285
+ */
1286
+ export function durableParentSeriesTerminal(args: {
1287
+ root: string
1288
+ gitFn: GitFn
1289
+ parentTicketRel: string
1290
+ parentState: WorkflowState
1291
+ parentId: string
1292
+ nowIso: string
1293
+ }): boolean {
1294
+ const terminals = readSeries(args.parentState).filter((s) => s.closed_reason === 'human-resolution')
1295
+ if (terminals.length === 0) return false
1296
+ let anyNew = false
1297
+ for (const s of terminals) {
1298
+ const resolution = s.human_resolution?.decision === 'replace' ? 'replace' : 'human-resolution'
1299
+ const abs = join(args.root, closeProofPath(args.parentTicketRel))
1300
+ const before = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
1301
+ appendCloseProofRowToDisk(args.root, args.parentTicketRel, {
1302
+ ticket_id: args.parentId,
1303
+ event: 'series-terminal',
1304
+ series_id: s.series_id,
1305
+ resolution,
1306
+ phase_inventory: null,
1307
+ design_ref: null,
1308
+ at: args.nowIso,
1309
+ reconstructed: false,
1310
+ evidence_basis: null,
1311
+ })
1312
+ const after = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
1313
+ if (after !== before) anyNew = true
1314
+ }
1315
+ if (!anyNew) return false // 전부 이미 있음(멱등)
1316
+ // close proof + ledger를 pathspec 커밋(부모 responses/ audit — 다른 staged 미접촉).
1317
+ const cpRel = closeProofPath(args.parentTicketRel)
1318
+ const ledgerRel = ledgerPath(args.parentTicketRel)
1319
+ const paths = existsSync(join(args.root, ledgerRel)) ? [cpRel, ledgerRel] : [cpRel]
1320
+ args.gitFn(['add', '--', ...paths])
1321
+ args.gitFn(['commit', '-m', `chore(${args.parentId}): series-terminal close proof (replace/human-resolution)`, '--', ...paths])
1322
+ return true
1323
+ }
1324
+
1325
+ export function precallCommitLedgerRow(gitFn: GitFn, ticketRel: string, ticketId: string, attempt: AttemptInfo): void {
1326
+ const ledgerRel = ledgerPath(ticketRel)
1327
+ gitFn(['add', '--', ledgerRel]) // untracked/modified 원장을 스테이징(오직 이 경로).
1328
+ gitFn([
1329
+ 'commit',
1330
+ '-m',
1331
+ `chore(${ticketId}): ledger attempt-opened ${attempt.series_id} #${attempt.attempt}`,
1332
+ '--', // 🔴 pathspec 커밋 — 이 경로만. staged design/code는 인덱스에 그대로 남는다.
1333
+ ledgerRel,
1334
+ ])
1335
+ }
1336
+
1337
+ export interface AttemptInfo {
1338
+ /** 열린 series의 id. 원장 자연키의 일부다. */
1339
+ series_id: string
1340
+ /** 이 호출이 몇 번째 attempt인가(recordAttempt 이후 값). */
1341
+ attempt: number
1342
+ /** autoBudget 초과라 사람 예외를 소비했는지 — scratch에서 지워지는 유일한 사실(REQ-2026-051). */
1343
+ exception_consumed: boolean
1344
+ }
1345
+
1346
+ /**
1347
+ * attempt 게이트+기록의 **앞부분만**(REQ-2026-052 phase-2에서 분리): terminal 가드 → 예산 → 예외 소비 →
1348
+ * recordAttempt → writeState. 반환 `{state, attempt}`. **외부 호출도 원장 쓰기도 하지 않는다.**
1349
+ *
1350
+ * 왜 분리했나: B2는 attempt 기록 **후, 외부 호출 전**에 원장 opened를 커밋하고 **그 다음에** approval binding을
1351
+ * 캡처해야 한다(DEC-A6). 그러려면 "기록"과 "호출" 사이에 커밋·캡처 단계가 들어간다. `withAttemptRecorded`는
1352
+ * 이 함수 + onAttemptOpened + call로 그대로 재조립돼 기존 계약·테스트를 보존한다.
1353
+ */
1354
+ export function gateAndRecordAttempt(ctx: {
1355
+ ticketDir: string
1356
+ state: WorkflowState
1357
+ kind: ReviewKind
1358
+ phaseId: string | null
1359
+ budget: ReviewBudget
1360
+ }): { state: WorkflowState; attempt: AttemptInfo } {
1119
1361
  // REQ-2026-029 D2: terminal 가드 — 예산 게이트보다 **앞**. human-resolution으로 종결된 키는 예산을 볼
1120
1362
  // 필요도 없이 막는다(배분표 ③). 가드 없으면 recordAttempt가 새 series(0회)를 열어 예산이 리셋된다.
1121
1363
  if (isSeriesKeyTerminal(ctx.state, ctx.kind, ctx.phaseId))
@@ -1140,8 +1382,41 @@ export function withAttemptRecorded<T>(
1140
1382
  }
1141
1383
  const state = recordAttempt(gated, ctx.kind, ctx.phaseId)
1142
1384
  writeState(ctx.ticketDir, state) // 호출 **전** 영속 — throw해도 남는다
1385
+ const opened = openSeriesRecord(state, ctx.kind, ctx.phaseId)
1386
+ const info: AttemptInfo = {
1387
+ series_id: opened?.series_id ?? '',
1388
+ attempt: opened?.attempts ?? 0,
1389
+ exception_consumed: decision.kind === 'needs-exception',
1390
+ }
1391
+ return { state, attempt: info }
1392
+ }
1393
+
1394
+ export function withAttemptRecorded<T>(
1395
+ ctx: {
1396
+ ticketDir: string
1397
+ state: WorkflowState
1398
+ kind: ReviewKind
1399
+ phaseId: string | null
1400
+ budget: ReviewBudget
1401
+ /**
1402
+ * attempt 확정·영속 **직후, 외부 호출 직전**에 불린다(REQ-2026-051 D2 — `attempt-opened`).
1403
+ * 🔴 **여기서 던진 예외는 전파된다**(외부 호출 전에). `appendLedgerRowToDisk`가 쓰기 실패는 이미
1404
+ * 삼키므로, 여기까지 올라오는 예외는 **원장 무결성 손상**뿐이다 — 손상된 감사 원장 위에서 리뷰를
1405
+ * 시작하지 않는다(D5 fail-closed, D10 pre-review 게이트와 같은 자리).
1406
+ */
1407
+ onAttemptOpened?: (info: AttemptInfo) => void
1408
+ },
1409
+ call: () => T,
1410
+ ): { result: T; state: WorkflowState; attempt: AttemptInfo } {
1411
+ const { state, attempt: info } = gateAndRecordAttempt(ctx)
1412
+ // 🔴 원장 `attempt-opened`도 **호출 전**에 남는다 — 그래야 호출이 실패해 완료 기록이 없는 attempt가
1413
+ // "예산은 깎였는데 완료되지 않은 호출"로 관측된다(REQ-2026-051 요구사항 #1).
1414
+ // 🔴 **여기서 삼키지 않는다**(phase-2 리뷰 P1). `appendLedgerRowToDisk`가 이미 쓰기 실패는 삼키고
1415
+ // 읽기·검증 손상만 throw한다(D5/D6 분리). 그 throw는 **외부 호출 전에 전파**되어야 손상된 원장이
1416
+ // 리뷰를 시작조차 못 한다(D10 pre-review 게이트와 같은 자리). 여기서 catch하면 그 fail-closed가 죽는다.
1417
+ ctx.onAttemptOpened?.(info)
1143
1418
  const result = call() // throw면 그대로 전파(기록은 이미 선행)
1144
- return { result, state }
1419
+ return { result, state, attempt: info }
1145
1420
  }
1146
1421
 
1147
1422
  /** 같은 `(kind, phase_id)`의 열린 series record(없으면 undefined). series_id를 재구성하지 않고 직접 얻는다. */
@@ -1302,6 +1577,8 @@ export function processResponse(args: {
1302
1577
  threadId: string
1303
1578
  kind?: ReviewKind
1304
1579
  designHash?: string | null
1580
+ /** 🔴 REQ-2026-052 DEC-B5: phase 승인 시점의 committed design 결속(designValid 통과값). phase evidence에 핀. */
1581
+ phaseDesignRef?: string | null
1305
1582
  phaseId?: string | null
1306
1583
  designValid?: boolean
1307
1584
  schemaPath?: string
@@ -1396,6 +1673,7 @@ export function processResponse(args: {
1396
1673
  binding,
1397
1674
  phaseId: args.phaseId ?? null,
1398
1675
  designHash: null,
1676
+ phaseDesignRef: args.phaseDesignRef ?? null, // REQ-2026-052 DEC-B5: 승인 시점 design 결속 핀
1399
1677
  threadId,
1400
1678
  archive: args.archive,
1401
1679
  approvedAt: args.approvedAt ?? new Date().toISOString(),
@@ -1479,24 +1757,28 @@ export function resolveReviewOutcome(args: {
1479
1757
  export function buildBlockedReviewTarget(args: {
1480
1758
  kind: ReviewKind
1481
1759
  phaseId: string | null
1482
- binding: Binding
1483
- designHash?: string | null
1760
+ /** REQ-2026-052: semantic identity(review-target.ts). 원장 커밋에 불변인 리뷰 대상 정체성. */
1761
+ semanticIdentity: string
1484
1762
  }): BlockedReviewTarget {
1485
1763
  return {
1486
1764
  review_kind: args.kind,
1487
1765
  phase_id: args.kind === 'phase' ? args.phaseId ?? null : null,
1488
- review_base_sha: args.binding.reviewBaseSha,
1489
- review_binding: args.kind === 'design' ? args.designHash ?? args.binding.reviewTree : args.binding.reviewTree,
1766
+ semantic_identity: args.semanticIdentity,
1490
1767
  }
1491
1768
  }
1492
1769
 
1770
+ /**
1771
+ * 🔴 REQ-2026-052: `semantic_identity`로 비교한다. 구형 marker(이 필드가 없음 — pre-052)는 `a.semantic_identity`가
1772
+ * `undefined`라 어떤 identity와도 불일치 → **재판정**(short-circuit 안 함 → 한 번 신선 리뷰 → 새 marker 기록).
1773
+ * 구형 marker를 새 identity와 같다고 **추정하지 않는다**(사용자 constraint 3).
1774
+ */
1493
1775
  function sameBlockedReviewTarget(a: BlockedReviewMarker | undefined, b: BlockedReviewTarget): boolean {
1494
1776
  return (
1495
1777
  !!a &&
1778
+ typeof a.semantic_identity === 'string' && // 구형 marker(undefined) → false → 재판정
1496
1779
  a.review_kind === b.review_kind &&
1497
1780
  a.phase_id === b.phase_id &&
1498
- a.review_base_sha === b.review_base_sha &&
1499
- a.review_binding === b.review_binding
1781
+ a.semantic_identity === b.semantic_identity
1500
1782
  )
1501
1783
  }
1502
1784
 
@@ -1573,10 +1855,8 @@ function escapeRegExp(s: string): string {
1573
1855
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1574
1856
  }
1575
1857
 
1576
- /** 아카이브 base(round namespace): design은 'design'(phaseId 무시), phase는 phaseId(없으면 'phase'=레거시). */
1577
- export function archiveBaseName(kind: ReviewKind, phaseId: string | null): string {
1578
- return kind === 'design' ? 'design' : phaseId && phaseId.length > 0 ? phaseId : 'phase'
1579
- }
1858
+ // `archiveBaseName`도 같은 이유로 `lib/evidence.ts`로 이동(아래 re-export). 아카이브 이름 규칙은
1859
+ // 매니페스트 검증(`validateManifest`)과 stage 목록(`expectedArchivePaths`) 공유하는 계약이다.
1580
1860
 
1581
1861
  /** 아카이브 파일명 — r 2자리 zero-pad. */
1582
1862
  export function archiveFileName(base: string, round: number, status: 'approved' | 'needs-fix'): string {
@@ -1607,11 +1887,13 @@ export function buildApprovalEvidence(args: {
1607
1887
  binding: Binding
1608
1888
  phaseId: string | null
1609
1889
  designHash: string | null
1890
+ /** 🔴 REQ-2026-052 DEC-B5: phase 승인의 design 결속(승인 시점 committed design 참조). design kind면 무시. */
1891
+ phaseDesignRef?: string | null
1610
1892
  threadId: string
1611
1893
  archive: { path: string; sha256: string }
1612
1894
  approvedAt: string
1613
1895
  }): ApprovalEvidence {
1614
- const { kind, verdict, binding, phaseId, designHash, threadId, archive, approvedAt } = args
1896
+ const { kind, verdict, binding, phaseId, designHash, phaseDesignRef, threadId, archive, approvedAt } = args
1615
1897
  const ev: ApprovalEvidence = {
1616
1898
  response_path: archive.path,
1617
1899
  response_sha256: archive.sha256,
@@ -1624,7 +1906,10 @@ export function buildApprovalEvidence(args: {
1624
1906
  commit_approved: verdict.commit_approved ?? '',
1625
1907
  approved_at: approvedAt,
1626
1908
  }
1627
- return kind === 'design' ? { ...ev, design_hash: designHash ?? null } : { ...ev, approved_tree: binding.reviewTree }
1909
+ // REQ-2026-052 DEC-B5: phase 승인엔 design 결속(phase_design_ref)을 함께 핀한다. design 행엔 넣지 않는다(kind 격리).
1910
+ return kind === 'design'
1911
+ ? { ...ev, design_hash: designHash ?? null }
1912
+ : { ...ev, approved_tree: binding.reviewTree, phase_design_ref: phaseDesignRef ?? null }
1628
1913
  }
1629
1914
 
1630
1915
  /**
@@ -1761,6 +2046,12 @@ export function callReviewer(
1761
2046
  respPath: string
1762
2047
  model: string | null
1763
2048
  reasoningEffort: string | null
2049
+ /**
2050
+ * 🔴 REQ-2026-054(DEC-C1): thread_id **파싱 성공 즉시**(respPath 기록 前) 호출된다 — 호출자가 dispatch
2051
+ * 확인 시점을 정확히 안다. 그래야 thread_id 확보 **후** respPath I/O 실패도 dispatch_confirmed로 분류된다
2052
+ * (반환 후에만 알면 이 경로가 dispatched_unknown으로 오분류됨).
2053
+ */
2054
+ onDispatchConfirmed?: (threadId: string) => void
1764
2055
  },
1765
2056
  ): { threadId: string } {
1766
2057
  const { lastMessage, threadId } = rv.review({
@@ -1771,7 +2062,9 @@ export function callReviewer(
1771
2062
  model: opts.model,
1772
2063
  reasoningEffort: opts.reasoningEffort,
1773
2064
  })
1774
- if (!threadId) throw new Error('thread_id 파싱 실패 (codex exec --json에 thread.started 없음)')
2065
+ // thread_id 없음 = codex는 실행됐으나(exit 0) 출력이 무효 = dispatched(차감), pre-dispatch 아님.
2066
+ if (!threadId) throw new ReviewCallError('dispatched', 'thread_id 파싱 실패 (codex exec --json에 thread.started 없음)')
2067
+ opts.onDispatchConfirmed?.(threadId) // 🔴 respPath 기록 前 — 이후 I/O 실패도 dispatch_confirmed.
1775
2068
  writeFileSync(opts.respPath, lastMessage, 'utf8')
1776
2069
  return { threadId }
1777
2070
  }
@@ -1816,7 +2109,8 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
1816
2109
  const handoffPath = opts.handoff ? resolve(opts.handoff) : cfg.handoffPathAbs
1817
2110
  const handoff = handoffPath && existsSync(handoffPath) ? readFileSync(handoffPath, 'utf8') : null
1818
2111
 
1819
- const { reviewBaseSha, reviewTree } = captureGitBinding()
2112
+ // 🔴 REQ-2026-052 DEC-A6: approval binding(captureGitBinding)은 여기서 캡처하지 않는다 — pre-call 원장
2113
+ // 커밋 **후**에 캡처해야 reviewBaseSha/reviewTree가 실제 post-commit 값이 된다(DRY-RUN·LIVE 각 분기에서).
1820
2114
  const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'])
1821
2115
  const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
1822
2116
 
@@ -1827,6 +2121,10 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
1827
2121
 
1828
2122
  // 추적 phase(phaseId)는 유효 design 승인(D13 동일: design_approved + freshness) 전제.
1829
2123
  let designValid = false
2124
+ // 🔴 REQ-2026-052 DEC-B5: designValid가 참일 때의 currentHash = 이 phase가 결속된 committed design 참조.
2125
+ // 승인 시 phase evidence(phase_design_ref)에 핀한다. designValid=false면 아래(step 2a)에서 fail-closed라
2126
+ // finalize에 도달하지 않으므로 null로 남아도 안전.
2127
+ let phaseDesignRef: string | null = null
1830
2128
  if (phaseId) {
1831
2129
  const approvedHash = typeof state.design_approved_hash === 'string' ? state.design_approved_hash : null
1832
2130
  let currentHash: string | null = null
@@ -1836,6 +2134,7 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
1836
2134
  currentHash = null
1837
2135
  }
1838
2136
  designValid = state.design_approved === true && approvedHash !== null && approvedHash === currentHash
2137
+ if (designValid) phaseDesignRef = currentHash
1839
2138
  }
1840
2139
 
1841
2140
  // kind별 권위 아티팩트: phase=staged diff, design=설계 문서 00/01/02 + design 바인딩 해시(결정#3·#4).
@@ -1855,42 +2154,56 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
1855
2154
  const baseline = state.design_baseline
1856
2155
  if (hasDesignBaseline(state) && baseline) designDelta = computeDesignDelta(baseline, designDocBlobs)
1857
2156
  } else {
1858
- stagedDiff = git(['diff', '--cached'])
2157
+ // REQ-2026-056: lockfile diff 프롬프트에서 요약(전문 opt-in = cfg.lockfilePromptFull). 🔴 프롬프트 문자열만
2158
+ // 바꾼다 — 바인딩(reviewTree = git write-tree)은 전체 index라 무영향(승인은 여전히 전체 lockfile 결속).
2159
+ stagedDiff = summarizeLockfileDiff(git(['diff', '--cached']), { full: cfg.lockfilePromptFull })
1859
2160
  }
1860
2161
  // REQ-2026-034 B-2b: delta 모드(designDelta 설정 = design + baseline)면 persona에 delta 계약을 얹는다.
1861
2162
  // 이 effectivePersona **하나**를 프롬프트와 review-call 로그 policy_version 양쪽에 흘린다(단일 배선, 032 r02·r03).
1862
2163
  // designDelta는 B-2a에서 design 전용이므로 phase·full은 base 그대로(kind 격리 구조적 재사용, 032 r06-2).
1863
2164
  const effectivePersona = applyDeltaPersona(persona, designDelta !== undefined)
1864
- const blockedTarget = buildBlockedReviewTarget({
1865
- kind: opts.kind,
1866
- phaseId,
1867
- binding: { reviewBaseSha, reviewTree },
1868
- designHash,
1869
- })
1870
2165
 
1871
- const reviewContext: ReviewContext = {
1872
- branch,
1873
- reviewBaseSha,
1874
- reviewTree,
1875
- phase: state.phase,
1876
- // REQ-2026-013 P4: 직전 same-target NEEDS_FIX findings만 주입(교차-대상이면 null). 옛 무조건 previous_codex_result 제거.
1877
- previousFindingsToClose: buildPreviousFindingsBlock(state, opts.kind, phaseId),
1878
- }
1879
- const prompt = assembleReviewPrompt({
1880
- persona: effectivePersona, // REQ-2026-034 B-2b: delta면 base+계약(null이면 계약 단독), 아니면 base 그대로.
1881
- handoff,
1882
- reviewContext,
1883
- reviewBaseSha,
1884
- requestBody,
1885
- reviewKind: opts.kind,
1886
- stagedDiff,
1887
- designDocs,
1888
- designDelta, // REQ-2026-033 B-2a: design 재리뷰(baseline 有)일 때만 값. 없으면 full 플레인 블록(무회귀).
1889
- })
2166
+ // ── REQ-2026-052 DEC-A6 step 1: semantic identity 후보(원장 커밋 **전**, binding 무관) ──
2167
+ const semanticIdentity = computeReviewSemanticIdentity(ticketRel, git)
2168
+ const isDurable = !isLegacyTicket(state) // pre-call 커밋은 durable 티켓만 — legacy는 기존 경로 그대로.
2169
+
2170
+ // blocked target = semantic identity 키잉(DEC-A5). base_sha/reviewTree가 아니라 원장-불변 정체성.
2171
+ const blockedTarget = buildBlockedReviewTarget({ kind: opts.kind, phaseId, semanticIdentity })
2172
+
2173
+ // 프롬프트 블록 원천 — state 재할당(attempt 기록) **전** 원본에서 계산.
2174
+ const previousFindingsBlock = buildPreviousFindingsBlock(state, opts.kind, phaseId)
2175
+ const previousFindingsCountVal = previousFindingsCount(state, opts.kind, phaseId)
2176
+ const originalPhase = state.phase
1890
2177
  const previewPath = join(ticketDir, '.review-preview.txt')
1891
- writeFileSync(previewPath, prompt, 'utf8')
2178
+
2179
+ // 실제 approval binding으로 프롬프트를 짓는 helper(DRY-RUN·LIVE 공유). reviewBaseSha/reviewTree는 호출 시점 실제값.
2180
+ const buildPromptFor = (rbSha: string, rTree: string): string => {
2181
+ const reviewContext: ReviewContext = {
2182
+ branch,
2183
+ reviewBaseSha: rbSha,
2184
+ reviewTree: rTree,
2185
+ phase: originalPhase,
2186
+ // REQ-2026-013 P4: 직전 same-target NEEDS_FIX findings만 주입(교차-대상이면 null).
2187
+ previousFindingsToClose: previousFindingsBlock,
2188
+ }
2189
+ return assembleReviewPrompt({
2190
+ persona: effectivePersona, // REQ-2026-034 B-2b: delta면 base+계약(null이면 계약 단독), 아니면 base 그대로.
2191
+ handoff,
2192
+ reviewContext,
2193
+ reviewBaseSha: rbSha,
2194
+ requestBody,
2195
+ reviewKind: opts.kind,
2196
+ stagedDiff,
2197
+ designDocs,
2198
+ designDelta, // REQ-2026-033 B-2a: design 재리뷰(baseline 有)일 때만 값. 없으면 full 플레인 블록(무회귀).
2199
+ })
2200
+ }
1892
2201
 
1893
2202
  if (!opts.run) {
2203
+ // ── DRY-RUN: binding 캡처(커밋 없음) → 프롬프트 → 출력 → return ──
2204
+ const { reviewBaseSha, reviewTree } = captureGitBinding()
2205
+ const prompt = buildPromptFor(reviewBaseSha, reviewTree)
2206
+ writeFileSync(previewPath, prompt, 'utf8')
1894
2207
  console.log('[req:review-codex] DRY-RUN (--run 지정 시 라이브 호출)')
1895
2208
  console.log(
1896
2209
  ` ticket=${ticketDir} REQ=${state.id} phase=${state.phase} branch=${branch} kind=${opts.kind} phaseId=${phaseId ?? '(none)'}`,
@@ -1902,48 +2215,93 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
1902
2215
  return
1903
2216
  }
1904
2217
 
1905
- // ── LIVE (단계 3B) ──
2218
+ // ── LIVE (단계 3B) — DEC-A6 순서 고정 ──
1906
2219
  const respPath = join(ticketDir, 'codex-response.json')
1907
2220
  const repoRel = (abs: string) => relative(cfg.root, abs).replace(/\\/g, '/')
1908
- // 워크플로 도구가 쓰는 메타데이터(응답·프리뷰·state)는 리뷰 대상 아님 → exact 경로로 허용(precondition/무수정검증 제외).
1909
- // 특히 state.json은 직전 라운드 writeState로 unstaged가 되므로 resume(2회차) precondition 통과에 필수(4C e2e 발견).
1910
2221
  const SCRATCH = reviewScratchPaths(ticketRel)
1911
- // --fresh-thread면 codex_thread_id가 있어도 resume하지 않고 exec으로 시작(고착 스레드 회복).
1912
- // REQ-2026-013 P4: 재리뷰는 **항상 stateless**(새 스레드). resume 누적이 토큰·goalpost drift의 원인이었다(D5).
1913
- // `codex_thread_id`는 계속 저장하되(후속 resume opt-in REQ용) resume에 쓰지 않는다. 연속성은 previous_findings_to_close로.
1914
- // `--fresh-thread`는 여전히 blocked 회로차단기 마커를 초기화한다(위 clearBlockedReview) — 그 회복 의미는 보존된다.
1915
- const isResume = false
2222
+ const isResume = false // REQ-2026-013 P4: 재리뷰는 항상 stateless. codex_thread_id는 저장만.
1916
2223
 
1917
- // Phase 4: 추적 phase는 유효 design 승인 전제(D13 동일) — 미충족 시 호출 전 fail-closed(불필요 codex 호출 방지).
2224
+ // DEC-A6 step 2a: phase는 유효 design 승인 전제(D13 동일) — 미충족 시 호출·커밋·기록 전 fail-closed.
1918
2225
  if (phaseId && !designValid)
1919
2226
  throw new Error(
1920
2227
  'phase 리뷰 전 유효 design 승인 필요(design_approved=true + 현재 00/01/02 해시 일치) — 설계 재승인 후 진행하세요.',
1921
2228
  )
1922
2229
 
1923
- // D10 precondition: 리뷰 워킹트리는 staged + 스크래치만 (사후 무수정 검증의 전제 — Codex P1).
1924
- // A2: ticketRel 전달 → 직전 라운드 untracked 아카이브(responses/)는 스크래치 허용, tracked 변조·approvals.jsonl은 flag.
2230
+ // DEC-A6 step 2b: D10 pre-review 가드(사후 무수정 검증의 전제).
1925
2231
  const preDirty = findUnstagedOrUntracked(gitStatusEntries(), SCRATCH, ticketRel)
1926
2232
  if (preDirty.length)
1927
2233
  throw new Error(
1928
2234
  `리뷰 전 워킹트리에 unstaged/untracked 존재(D10) — 의도 변경은 git add, 그 외 정리 필요:\n ${preDirty.map(formatStatusEntry).join('\n ')}`,
1929
2235
  )
1930
2236
 
2237
+ // 🔴 DEC-A6 step 2c: blocked short-circuit — **커밋·attempt 기록·예산 소비·외부 호출보다 먼저**.
2238
+ // 같은 semantic target이 이미 blocked면 그 넷 중 어느 것도 일어나지 않는다(사용자 불변식).
1931
2239
  if (shouldShortCircuitBlockedReview(state, blockedTarget)) {
1932
- console.error('[req:review-codex] BLOCKED repeated blocked result for the same review binding')
1933
- console.error(' Codex will not be called again for this unchanged target. Change the staged binding or escalate.')
2240
+ console.error('[req:review-codex] BLOCKED repeated blocked result for the same review target (semantic identity)')
2241
+ console.error(' Codex will not be called again for this unchanged target. Change the staged review target or escalate.')
1934
2242
  process.exit(reviewOutcomeExitCode('blocked'))
1935
2243
  }
1936
2244
 
1937
- console.warn(`⚠️ codex 실제 호출 (${isResume ? 'resume' : 'exec'}) 호출 1회 발생 (DEC-WF-026: 호출 직전 확인)`)
2245
+ // DEC-A6 step 3: 예산/예외 gate + attempt-opened 기록(state.json scratch). 반환 state가 이후 base(R9).
2246
+ const { state: afterAttempt, attempt: attemptInfo } = gateAndRecordAttempt({
2247
+ ticketDir,
2248
+ state,
2249
+ kind: opts.kind,
2250
+ phaseId,
2251
+ budget: cfg.reviewBudget,
2252
+ })
2253
+ state = afterAttempt
2254
+
2255
+ // DEC-A6 step 4: 원장 attempt-opened append + pre-call ledger-only commit(durable 티켓만·fail-closed).
2256
+ // opened 행의 prompt_sha256은 **null** — 프롬프트는 아직 안 지었다(binding이 이 커밋 뒤라야 확정).
2257
+ // 순환의존을 끊는다: prompt_sha256은 나중에 attempt-closed 행에만 채운다.
2258
+ appendLedgerRowToDisk(cfg.root, ticketRel, {
2259
+ ticket_id: String(state.id ?? ''),
2260
+ review_kind: opts.kind,
2261
+ phase_id: phaseId,
2262
+ series_id: attemptInfo.series_id,
2263
+ attempt: attemptInfo.attempt,
2264
+ event: 'attempt-opened',
2265
+ lifecycle: null,
2266
+ outcome: null,
2267
+ exception_consumed: attemptInfo.exception_consumed,
2268
+ prompt_sha256: null,
2269
+ at: new Date().toISOString(),
2270
+ reconstructed: false,
2271
+ })
2272
+ if (isDurable) precallCommitLedgerRow(git, ticketRel, String(state.id ?? ''), attemptInfo)
2273
+
2274
+ // DEC-A6 step 5: 실제 approval binding 캡처(pre-call 커밋 **후** — 실제 HEAD/전체 index tree).
2275
+ const { reviewBaseSha, reviewTree } = captureGitBinding()
2276
+
2277
+ // DEC-A6 step 6: semantic identity 재계산 + pre-commit 값과 동일 assert(원장 커밋이 audit-only임을 검증).
2278
+ const semanticIdentityAfter = computeReviewSemanticIdentity(ticketRel, git)
2279
+ if (semanticIdentityAfter !== semanticIdentity)
2280
+ throw new Error(
2281
+ `pre-call 원장 커밋이 semantic identity를 바꿨다(audit-only 위반 — responses/ 밖 변경이 커밋에 딸림): ${semanticIdentity} → ${semanticIdentityAfter}`,
2282
+ )
1938
2283
 
1939
- // ReviewerAdapter 경유(Phase 3). exec/resume 분기·--output-last-message·thread 파싱은 어댑터가 담당.
1940
- // resumeThreadId 있으면 resume(thread 상속), 없으면 exec(--sandbox read-only) → thread.started 파싱.
1941
- // REQ-2026-027 D3: attempt를 **호출 직전**에 기록·writeState(withAttemptRecorded). 반환 state(afterAttempt)
1942
- // 이후 모든 처리의 base다 — 호출 전 `state`를 다시 쓰면 최종 writeState가 attempt를 되돌린다(R9).
1943
- const { result: callRes, state: afterAttempt } = withAttemptRecorded(
1944
- { ticketDir, state, kind: opts.kind, phaseId, budget: cfg.reviewBudget },
1945
- () =>
1946
- callReviewer(reviewer, {
2284
+ // DEC-A6 step 7: 프롬프트 조립(실제 binding) + 외부 호출.
2285
+ const prompt = buildPromptFor(reviewBaseSha, reviewTree)
2286
+ writeFileSync(previewPath, prompt, 'utf8')
2287
+ const promptSha256 = createHash('sha256').update(prompt, 'utf8').digest('hex')
2288
+ console.warn(`⚠️ codex 실제 호출 (${isResume ? 'resume' : 'exec'}) 호출 1회 발생 (DEC-WF-026: 호출 직전 확인)`)
2289
+ let reviewDurationMs = 0 // REQ-2026-045: callReviewer 소요(측정 전용).
2290
+ // 🔴 REQ-2026-054(DEC-C4): dispatch 구간(callReviewer → 사후 tamper 검증 → 응답 파싱)을 try/catch로 감싼다.
2291
+ // 실패 시 lifecycle을 분류해 **보상 attempt-closed**를 남기고(durable이면 pathspec 커밋), **명백한
2292
+ // pre-dispatch면 회차를 환불**한 뒤 **원본 오류를 re-throw**(fail-closed). `dispatchConfirmed`는 thread_id
2293
+ // 확보 즉시(onDispatchConfirmed) true — 그 후 실패(respPath I/O·tamper·응답 파싱)는 dispatch_confirmed.
2294
+ // 이 try는 outcome이 정해지기 **전**(probe)에서 끝난다 → 정상 경로의 attempt-closed(completed)와 상호배타.
2295
+ let dispatchConfirmed = false
2296
+ let threadId!: string
2297
+ let approvedAt!: string
2298
+ let baseArgs!: Parameters<typeof processResponse>[0]
2299
+ let probe!: ReturnType<typeof processResponse>
2300
+ try {
2301
+ const callStartMs = Date.now()
2302
+ let callRes: ReturnType<typeof callReviewer>
2303
+ try {
2304
+ callRes = callReviewer(reviewer, {
1947
2305
  prompt,
1948
2306
  schemaPath: cfg.schemaPathAbs,
1949
2307
  resumeThreadId: isResume ? (state.codex_thread_id as string) : null,
@@ -1952,35 +2310,76 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
1952
2310
  // REQ-2026-013 P1: 리뷰 모델·추론강도 override를 config에서 채워 어댑터로 전달(null이면 어댑터가 `-c` 생략).
1953
2311
  model: cfg.reviewModel,
1954
2312
  reasoningEffort: cfg.reviewReasoningEffort,
1955
- }),
1956
- )
1957
- const { threadId } = callRes
1958
- state = afterAttempt // 이후 baseArgs·finalState의 base
1959
-
1960
- // 사후 리뷰어 무수정 검증: worktree 절대검사 + index(staged tree OID) 불변 (content 기반)
1961
- const postDirty = findUnstagedOrUntracked(gitStatusEntries(), SCRATCH, ticketRel)
1962
- if (postDirty.length)
1963
- throw new Error(`리뷰 호출 후 워킹트리 변경(리뷰어 수정?):\n ${postDirty.map(formatStatusEntry).join('\n ')}`)
1964
- const afterTree = git(['write-tree'])
1965
- if (afterTree !== reviewTree)
1966
- throw new Error(`리뷰 호출 후 staged tree 변경(리뷰어 index 수정?): ${reviewTree} → ${afterTree}`)
1967
-
1968
- // A2(D-016-1 · A2-P2-2): reviewer 무수정 검증 이후, **검증 먼저(아카이브 없이)** → archiveDecision으로 suffix/생략 결정 → 기록.
1969
- // 무효(kind 불일치·모순 )면 아카이브를 남기지 않는다(round/finalize 오염 방지). 유효 NEEDS_FIX는 보존, 승인은 evidence 핀 부착.
1970
- const approvedAt = new Date().toISOString()
1971
- const baseArgs = {
1972
- ticketDir,
1973
- state,
1974
- binding: { reviewBaseSha, reviewTree },
1975
- threadId,
1976
- kind: opts.kind,
1977
- designHash,
1978
- phaseId,
1979
- designValid,
1980
- schemaPath: cfg.schemaPathAbs,
1981
- designDocBlobs, // REQ-2026-031 B-1: design kind일 때만 값 있음. 승인+archive 분기에서만 baseline에 저장.
2313
+ onDispatchConfirmed: () => {
2314
+ dispatchConfirmed = true
2315
+ },
2316
+ })
2317
+ } finally {
2318
+ reviewDurationMs = Date.now() - callStartMs
2319
+ }
2320
+ threadId = callRes.threadId
2321
+
2322
+ // 사후 리뷰어 무수정 검증: worktree 절대검사 + index(staged tree OID) 불변 (content 기반)
2323
+ const postDirty = findUnstagedOrUntracked(gitStatusEntries(), SCRATCH, ticketRel)
2324
+ if (postDirty.length)
2325
+ throw new Error(`리뷰 호출 후 워킹트리 변경(리뷰어 수정?):\n ${postDirty.map(formatStatusEntry).join('\n ')}`)
2326
+ const afterTree = git(['write-tree'])
2327
+ if (afterTree !== reviewTree)
2328
+ throw new Error(`리뷰 호출 후 staged tree 변경(리뷰어 index 수정?): ${reviewTree} → ${afterTree}`)
2329
+
2330
+ // A2(D-016-1 · A2-P2-2): reviewer 무수정 검증 이후, **검증 먼저(아카이브 없이)** → archiveDecision으로 suffix/생략 결정 → 기록.
2331
+ // 무효(kind 불일치·모순 등)면 아카이브를 남기지 않는다(round/finalize 오염 방지). 유효 NEEDS_FIX는 보존, 승인은 evidence 핀 부착.
2332
+ approvedAt = new Date().toISOString()
2333
+ baseArgs = {
2334
+ ticketDir,
2335
+ state,
2336
+ binding: { reviewBaseSha, reviewTree },
2337
+ threadId,
2338
+ kind: opts.kind,
2339
+ designHash,
2340
+ phaseDesignRef, // REQ-2026-052 DEC-B5: phase 승인 시점 design 결속(designValid 통과값)
2341
+ phaseId,
2342
+ designValid,
2343
+ schemaPath: cfg.schemaPathAbs,
2344
+ designDocBlobs, // REQ-2026-031 B-1: design kind일 때만 값 있음. 승인+archive 분기에서만 baseline에 저장.
2345
+ }
2346
+ probe = processResponse(baseArgs) // 아카이브 없이 검증(진짜 승인 여부·유효성)
2347
+ } catch (err) {
2348
+ // 🔴 DEC-C4 보상 경로. classifyDispatchFailure로 lifecycle 판정 → attempt-closed(invalid) 기록 → durable이면
2349
+ // 원장 pathspec 커밋(best-effort) → pre-dispatch면 환불 → 원본 오류 re-throw.
2350
+ const lifecycle = classifyDispatchFailure(err, dispatchConfirmed)
2351
+ try {
2352
+ appendLedgerRowToDisk(cfg.root, ticketRel, {
2353
+ ticket_id: String(state.id ?? ''),
2354
+ review_kind: opts.kind,
2355
+ phase_id: phaseId,
2356
+ series_id: attemptInfo.series_id,
2357
+ attempt: attemptInfo.attempt,
2358
+ event: 'attempt-closed',
2359
+ lifecycle, // pre_dispatch_failed | dispatched_unknown | dispatch_confirmed
2360
+ outcome: 'invalid', // 실패한 호출 — 유효 판정 없음.
2361
+ exception_consumed: attemptInfo.exception_consumed,
2362
+ prompt_sha256: null, // 실패 경로는 프롬프트 해시를 담지 않는다(opened와 동일).
2363
+ at: new Date().toISOString(),
2364
+ reconstructed: false,
2365
+ })
2366
+ if (isDurable) {
2367
+ // 원장 modified가 남으면 다음 리뷰 D10이 막힌다 → attempt-opened와 동일 조건·기법으로 pathspec 커밋.
2368
+ const ledgerRel = ledgerPath(ticketRel)
2369
+ git(['add', '--', ledgerRel])
2370
+ git(['commit', '-m', `chore(${String(state.id ?? '')}): ledger attempt-closed ${attemptInfo.series_id} #${attemptInfo.attempt} (${lifecycle})`, '--', ledgerRel])
2371
+ }
2372
+ } catch (compErr) {
2373
+ // 보상 기록/커밋 실패는 삼킨다 — 원본 dispatch 오류가 전파돼야 한다(판정을 가리지 않는다).
2374
+ console.warn(`[req:review-codex] ⚠️ 보상 attempt-closed 기록/커밋 실패(원본 오류 전파): ${compErr instanceof Error ? compErr.message : String(compErr)}`)
2375
+ }
2376
+ if (lifecycle === 'pre_dispatch_failed') {
2377
+ // 🔴 명백한 pre-dispatch만 환불(유효 회차 -1 효과). attempts는 단조 유지 → 재시도가 원장 자연키 충돌 없음.
2378
+ state = refundAttempt(state, opts.kind, phaseId)
2379
+ writeState(ticketDir, state)
2380
+ }
2381
+ throw err // fail-closed — 실패는 실패(exit 비-0).
1982
2382
  }
1983
- const probe = processResponse(baseArgs) // 아카이브 없이 검증(진짜 승인 여부·유효성)
1984
2383
  const decision = archiveDecision(probe, opts.kind) // null=아카이브 안 함(무효), 'approved'|'needs-fix'
1985
2384
  let archiveDesc: { path: string; sha256: string } | undefined
1986
2385
  // REQ-2026-025 D4: 측정 로그의 archive_round. 무효 응답은 아카이브를 남기지 않으므로 null로 남는다.
@@ -2009,8 +2408,10 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
2009
2408
  const responseSha256 = existsSync(respPath)
2010
2409
  ? createHash('sha256').update(readFileSync(respPath)).digest('hex')
2011
2410
  : null
2012
- // D6-2: req:next가 write-tree 없이 재계산할 있는 바인딩 해시. design=designHash, phase=인덱스 전체 해시.
2013
- const compareHash = opts.kind === 'design' ? designHash ?? null : captureIndexHash()
2411
+ // 🔴 REQ-2026-052: compare_hash = **semantic identity**(design·phase 통일). pre-call 원장 커밋·evidence-finalize
2412
+ // 양쪽 audit 변화에 불변이라, 방금 승인·내구화한 리뷰를 req:next G2가 stale로 오판하지 않는다.
2413
+ // read-only(ls-files) — req:next가 write-tree 없이 재계산 가능. semanticIdentity(step 6 assert 통과값)를 재사용.
2414
+ const compareHash = semanticIdentity
2014
2415
  const { outcome, exitCode, finalState } = resolveReviewOutcome({
2015
2416
  result,
2016
2417
  kind: opts.kind,
@@ -2024,6 +2425,59 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
2024
2425
  const persistedState = outcome === 'approved' ? closeSeriesApproved(finalState, opts.kind, phaseId) : finalState
2025
2426
  writeState(ticketDir, persistedState)
2026
2427
 
2428
+ // REQ-2026-051 D2: 원장 `attempt-closed`. 🔴 **durableDesignEvidence 커밋보다 먼저** 써야 한다 —
2429
+ // 아니면 design 승인 리뷰의 closed 행(outcome=approved, 가장 중요한 행)이 그 커밋에 실리지 못하고
2430
+ // 미커밋으로 남는다(phase-3 e2e가 이 순서를 고정). 대응하는 `attempt-opened`가 이미 있고, 이 행이
2431
+ // 없으면 그 attempt는 "완료되지 않은 호출"로 남는다(요구사항 #1). `approvedAt` 재사용(새 시계 안 읽음).
2432
+ appendLedgerRowToDisk(cfg.root, ticketRel, {
2433
+ ticket_id: String(state.id ?? ''),
2434
+ review_kind: opts.kind,
2435
+ phase_id: phaseId,
2436
+ series_id: attemptInfo.series_id,
2437
+ attempt: attemptInfo.attempt,
2438
+ event: 'attempt-closed',
2439
+ lifecycle: 'completed', // 이 REQ는 completed만 쓴다. 실패 분류는 후속 REQ 소관(D3).
2440
+ outcome, // ReviewOutcome === LedgerOutcome. 캐스트하지 않는다 — 갈라지면 빌드가 깨져야 한다.
2441
+ exception_consumed: attemptInfo.exception_consumed,
2442
+ // 🔴 prompt_sha256은 opened가 아니라 closed에만 담는다(REQ-2026-052: opened는 프롬프트 확정 전에 커밋되므로).
2443
+ prompt_sha256: promptSha256,
2444
+ // 🔴 archive path·sha는 담지 않는다 — approvals.jsonl의 archive_inventory가 단일 출처다(phase-2 리뷰 P1).
2445
+ at: approvedAt,
2446
+ reconstructed: false,
2447
+ })
2448
+
2449
+ // ── REQ-2026-048 phase-3: design 승인 evidence **자동 내구화**(DEC-3) ──
2450
+ // 정상 승인 경로가 여기서 아카이브·매니페스트를 커밋한다 → 운영자가 `--finalize-design`을 따로 기억할
2451
+ // 필요가 없다. 그 수동 단계가 잊히면 design 감사 증거가 커밋 이력에 **전혀 남지 않던** 것이 이 REQ의 원인이다.
2452
+ //
2453
+ // 🔴 **실패를 삼킨다 — 승인 판정·exit code를 바꾸지 않는다.** 기록 실패가 게이트 결정을 뒤집으면 그것이
2454
+ // 계약 위반이다(측정 로그 R8과 같은 취지). 대신 복구 명령을 안내하고, 그 "승인됨·미커밋" 창은
2455
+ // `req:next`의 DONE 게이트(phase-4)가 잡는다. 멱등이라 재실행도 안전하다.
2456
+ if (opts.kind === 'design' && outcome === 'approved') {
2457
+ const dev = (persistedState.design_approval_evidence as ApprovalEvidence | undefined) ?? null
2458
+ if (dev) {
2459
+ const ticketRelForEvidence = repoRel(ticketDir)
2460
+ try {
2461
+ const r = durableDesignEvidence({
2462
+ ticketId: String(persistedState.id ?? ''),
2463
+ ticketRel: ticketRelForEvidence,
2464
+ evidence: dev,
2465
+ validPhaseIds: readPhases(persistedState).map((ph) => ph.id),
2466
+ nowIso: approvedAt,
2467
+ ports: createEvidencePorts(cfg.root, `${ticketRelForEvidence}/responses`),
2468
+ })
2469
+ if (r.outcome !== 'already-durable')
2470
+ console.log('[req:review-codex] design 승인 증거를 커밋했습니다(아카이브·approvals.jsonl).')
2471
+ } catch (err) {
2472
+ console.warn(
2473
+ `[req:review-codex] ⚠️ design 승인 증거 커밋 실패(승인 자체는 유효): ${err instanceof Error ? err.message : String(err)}
2474
+ ` +
2475
+ ` 복구: ${buildScriptInvocation(cfg.packageManager, 'req:commit', [String(persistedState.id ?? ''), '--finalize-design', '--run']).join(' ')}`,
2476
+ )
2477
+ }
2478
+ }
2479
+ }
2480
+
2027
2481
  // REQ-2026-025 D4: 완료된 review call 1행 기록(측정 전용). `approvedAt`을 재사용해 같은 call의 다른
2028
2482
  // 기록과 시각이 어긋나지 않게 한다 — 새 시계를 읽지 않는다. 실패는 삼켜진다(R8).
2029
2483
  appendReviewCallLog(
@@ -2040,6 +2494,13 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
2040
2494
  // REQ-2026-043: codex에 흘러간 값(1942-1943)과 동일 원천. null=미핀(전역 상속).
2041
2495
  reviewModel: cfg.reviewModel,
2042
2496
  reviewReasoningEffort: cfg.reviewReasoningEffort,
2497
+ // REQ-2026-045: 관측성 지원 필드(개수/해시 — 내용배제 유지, 승인 증거 아님).
2498
+ promptBytes: assembledPromptBytes(prompt),
2499
+ reviewDurationMs,
2500
+ previousFindingsCount: previousFindingsCountVal,
2501
+ assembledPromptSha256: createHash('sha256').update(prompt, 'utf8').digest('hex'),
2502
+ reviewBaseSha,
2503
+ reviewTree,
2043
2504
  }),
2044
2505
  )
2045
2506