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.
- package/CHANGELOG.md +40 -0
- package/bin/dispatch.mjs +3 -0
- package/bin/init.ts +16 -3
- package/bin/migrate.ts +29 -11
- package/bin/sync.ts +338 -30
- package/bin/uninstall.ts +8 -2
- package/package.json +76 -76
- package/scripts/req/lib/adapters.ts +99 -7
- package/scripts/req/lib/close-migrate.ts +135 -0
- package/scripts/req/lib/close-proof.ts +323 -0
- package/scripts/req/lib/config.ts +9 -0
- package/scripts/req/lib/evidence-ports.ts +116 -0
- package/scripts/req/lib/evidence.ts +813 -0
- package/scripts/req/lib/intake.ts +175 -0
- package/scripts/req/lib/lockfile-diff.ts +127 -0
- package/scripts/req/lib/reconstruct.ts +118 -0
- package/scripts/req/lib/review-exception.ts +233 -0
- package/scripts/req/lib/review-ledger.ts +257 -0
- package/scripts/req/lib/review-target.ts +61 -0
- package/scripts/req/lib/scratch.ts +14 -1
- package/scripts/req/req-close.ts +222 -0
- package/scripts/req/req-commit.ts +215 -256
- package/scripts/req/req-doctor.ts +134 -16
- package/scripts/req/req-new.ts +53 -1
- package/scripts/req/req-next.ts +59 -5
- package/scripts/req/req-reconstruct.ts +217 -0
- package/scripts/req/req-review-exception.ts +197 -0
- package/scripts/req/review-codex.ts +578 -117
- package/templates/workflow.gitignore +4 -0
- package/workflow/req.config.schema.json +3 -0
- package/workflow/review-persona.md +8 -0
|
@@ -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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
const
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
1933
|
-
console.error(' Codex will not be called again for this unchanged target. Change the staged
|
|
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
|
-
|
|
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
|
-
//
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
-
//
|
|
2013
|
-
|
|
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
|
|