commitgate 0.9.8 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * 커밋되는 **append-only 리뷰 원장** (REQ-2026-051).
3
+ *
4
+ * 왜 존재하나: `state.json`은 scratch로 설계돼 커밋되지 않는다(`req-commit.ts` — "state.json은 scratch 유지").
5
+ * 그래서 런타임 원장이 미커밋으로 남고, 다음 티켓의 `req:new`가 clean tree를 요구하는 순간 폐기된다.
6
+ * 소비자 저장소에서 실제로 한 REQ의 원장(설계 승인 + phase 3건 승인 이력)이 통째로 사라졌고,
7
+ * 이 저장소의 REQ-2026-049도 같은 상태다(`phases: []`·`review_series: 0`).
8
+ *
9
+ * 🔴 **`approvals.jsonl`을 중복하지 않는다.** 그쪽 `archive_inventory`가 아카이브된 전 라운드를
10
+ * `response_path`+`sha256`으로 이미 담는다. 이 원장은 **아카이브가 보여줄 수 없는 것만** 담는다:
11
+ * 1. 아카이브를 남기지 않은 시도(호출 실패·무효 응답) — attempt는 호출 **전**에 확정되고
12
+ * 아카이브는 유효 응답에만 생기므로, 그 차이가 어디에도 안 남는다.
13
+ * 2. 사람 예외 소비 — `review_exception_confirmed`는 소비 후 `null`로 지워진다.
14
+ * 3. series 종결 사유 · 4. lineage · 5. 재구성 여부.
15
+ *
16
+ * 🔴 **프롬프트·응답 본문을 저장하지 않는다.** 해시까지만. 응답 본문은 이미 아카이브에 있고
17
+ * `archive_sha256`이 그것을 가리킨다. 허용키 화이트리스트가 본문이 들어갈 자리 자체를 막는다.
18
+ *
19
+ * 이 모듈은 **순수**하다 — fs·git을 모른다. 부작용은 호출부가 낸다(`lib/evidence`와 같은 태도).
20
+ */
21
+ import { isValidIsoInstant } from './evidence'
22
+ import type { ReviewKind } from '../review-codex'
23
+
24
+ /** 원장 파일의 티켓-상대 경로. `approvals.jsonl`과 같은 디렉터리 — 기존 내구화 경로 2곳을 그대로 재사용한다. */
25
+ export const LEDGER_BASENAME = 'review-ledger.jsonl'
26
+
27
+ /** 티켓 `responses/` 기준 원장의 repo-상대 경로. */
28
+ export function ledgerPath(ticketRel: string): string {
29
+ return `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/${LEDGER_BASENAME}`
30
+ }
31
+
32
+ /**
33
+ * 이벤트 2종 (D2).
34
+ *
35
+ * attempt는 외부 호출 **전**에 확정되고 결과는 **후**에 나온다. 한 행에 담으면 나중에 그 행을 고쳐야 하는데
36
+ * 그것은 append-only가 아니다. 그래서 나눈다.
37
+ *
38
+ * 부수 효과가 이 설계의 핵심 이득이다: `attempt-opened`만 있고 `attempt-closed`가 없는 attempt가 곧
39
+ * **"예산은 깎였는데 완료되지 않은 호출"**이다. 별도 필드 없이 원장 **구조 자체로** 관측된다.
40
+ */
41
+ export type LedgerEvent = 'attempt-opened' | 'attempt-closed'
42
+
43
+ /** 완료된 attempt의 판정. 미완(`attempt-opened`)이면 null. */
44
+ export type LedgerOutcome = 'approved' | 'needs-fix' | 'blocked' | 'invalid'
45
+
46
+ export interface LedgerRow {
47
+ ticket_id: string
48
+ series_id: string
49
+ review_kind: ReviewKind
50
+ phase_id: string | null
51
+ attempt: number
52
+ event: LedgerEvent
53
+ /**
54
+ * 호출 수명주기. **이 REQ는 `completed`만 쓴다.** `pre_dispatch_failed`·`dispatch_confirmed`·
55
+ * `dispatched_unknown` 및 예산 차감 규칙 변경은 후속 REQ 소관이다.
56
+ * `attempt-opened`에서는 null.
57
+ */
58
+ lifecycle: string | null
59
+ outcome: LedgerOutcome | null
60
+ /** 이 attempt가 autoBudget 초과라 사람 예외를 소비했는지. scratch에서 지워지는 유일한 사실이라 여기서만 살아남는다. */
61
+ exception_consumed: boolean
62
+ // 🔴 **archive path·sha256을 담지 않는다**(phase-2 리뷰 P1). 아카이브된 라운드의 경로·해시는 이미
63
+ // `approvals.jsonl`의 `archive_inventory`가 단일 출처로 보관한다. 원장이 그걸 복제하면 두 사본이
64
+ // 갈라질 수 있고, 이 REQ의 헤드라인 원칙("approvals.jsonl을 중복하지 않는다")을 스스로 어긴다.
65
+ // 아카이브 존재 여부는 `outcome`이 이미 알려 준다(approved/needs-fix=아카이브됨, blocked/invalid=아님).
66
+ /** 🔴 프롬프트 **해시만**. 본문은 원장에 들어가지 않는다. */
67
+ prompt_sha256: string | null
68
+ at: string
69
+ /** 사후 복원한 기록인지. 원본과 구별할 수단이 없으면 재구성본이 원본으로 위장한다. */
70
+ reconstructed: boolean
71
+ }
72
+
73
+ /**
74
+ * 직렬화 키 순서(고정 — deterministic). `serializeManifestLine`과 같은 방식.
75
+ * 허용키 화이트리스트로도 쓰인다 — 여기 없는 top-level 키는 오염 신호로 거부한다.
76
+ *
77
+ * 🔴 **릴리스 후 스키마 변경은 additive-only여야 한다.** 키를 **제거**하면, 옛 스키마로 커밋된 기존 원장이
78
+ * 새 검증기에서 "알 수 없는 키"로 거부되고 D5 fail-closed가 그 티켓의 모든 리뷰를 막는다(실제로 이
79
+ * REQ의 phase-2 개발 중 dogfood에서 발생했다 — 미커밋 원장이라 재생성으로 해결). 이 REQ는 **릴리스 전**이라
80
+ * archive_path·archive_sha256을 제거해도 야생에 옛 원장이 없어 안전하다. 릴리스 후에는 키 추가만 하고,
81
+ * 값 확장은 forward-compatible하게(모르는 값은 거부 안 함, `lifecycle` 참조).
82
+ */
83
+ export const LEDGER_KEYS = [
84
+ 'ticket_id',
85
+ 'series_id',
86
+ 'review_kind',
87
+ 'phase_id',
88
+ 'attempt',
89
+ 'event',
90
+ 'lifecycle',
91
+ 'outcome',
92
+ 'exception_consumed',
93
+ 'prompt_sha256',
94
+ 'at',
95
+ 'reconstructed',
96
+ ] as const
97
+
98
+ const OUTCOMES: readonly string[] = ['approved', 'needs-fix', 'blocked', 'invalid']
99
+ const EVENTS: readonly string[] = ['attempt-opened', 'attempt-closed']
100
+
101
+ /** 한 줄 직렬화(JSONL): 고정 키 순서 JSON + 끝 개행. */
102
+ export function serializeLedgerRow(row: LedgerRow): string {
103
+ const o: Record<string, unknown> = {}
104
+ for (const k of LEDGER_KEYS) o[k] = row[k]
105
+ return `${JSON.stringify(o)}\n`
106
+ }
107
+
108
+ /**
109
+ * 자연키 구분자 — **US(0x1F, unit separator)**.
110
+ *
111
+ * 왜 제어문자인가: 티켓 id·series id·phase id 어디에도 나타날 수 없어야 구분자 충돌로 서로 다른
112
+ * attempt가 같은 키로 접히지 않는다. 가시 문자(공백·`|`·`:`)는 series id(`design:-#1`)나 장래의
113
+ * phase id에 나타날 수 있다.
114
+ *
115
+ * 🔴 **소스에 제어문자 리터럴을 넣지 않는다** — `String.fromCharCode`로 만든다. 이 phase에서 실제로
116
+ * 원시 NUL 바이트가 소스에 박혀 git이 파일을 **binary로 취급**했고 grep·diff·리뷰가 전부 깨졌다.
117
+ * 테스트(`제어문자 리터럴 없음`)가 이 재발을 잠근다.
118
+ */
119
+ const KEY_SEP = String.fromCharCode(31) // US(unit separator)
120
+
121
+ /** 자연키 — 멱등 판정의 단위(D5). */
122
+ export function ledgerRowKey(row: Pick<LedgerRow, 'ticket_id' | 'series_id' | 'attempt' | 'event'>): string {
123
+ return [row.ticket_id, row.series_id, String(row.attempt), row.event].join(KEY_SEP)
124
+ }
125
+
126
+ /**
127
+ * 행 하나의 형식 문제 목록(순수). 빈 배열 = 정상.
128
+ *
129
+ * 🔴 **비대칭이 의도다**(D3): 모르는 `lifecycle` **값**은 거부하지 않고(forward-compatible — 후속 REQ가
130
+ * 값을 늘리는 것이 정상 경로다), 모르는 top-level **키**는 거부한다(주입·오염 신호).
131
+ */
132
+ export function ledgerRowProblems(raw: unknown): string[] {
133
+ const p: string[] = []
134
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['객체가 아님']
135
+ const r = raw as Record<string, unknown>
136
+
137
+ const allowed = new Set<string>(LEDGER_KEYS)
138
+ for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`알 수 없는 키: ${k}`)
139
+ for (const k of LEDGER_KEYS) if (!(k in r)) p.push(`필수 키 누락: ${k}`)
140
+ if (p.length) return p
141
+
142
+ if (typeof r.ticket_id !== 'string' || r.ticket_id === '') p.push('ticket_id가 비어 있음')
143
+ if (typeof r.series_id !== 'string' || r.series_id === '') p.push('series_id가 비어 있음')
144
+ if (r.review_kind !== 'design' && r.review_kind !== 'phase') p.push(`review_kind 부적합: ${String(r.review_kind)}`)
145
+ if (r.phase_id !== null && (typeof r.phase_id !== 'string' || r.phase_id === '')) p.push('phase_id는 null이거나 비지 않은 문자열')
146
+ if (typeof r.attempt !== 'number' || !Number.isInteger(r.attempt) || r.attempt < 1) p.push('attempt는 1 이상 정수')
147
+ if (typeof r.event !== 'string' || !EVENTS.includes(r.event)) p.push(`event 부적합: ${String(r.event)}`)
148
+ if (typeof r.exception_consumed !== 'boolean') p.push('exception_consumed는 boolean')
149
+ if (typeof r.reconstructed !== 'boolean') p.push('reconstructed는 boolean')
150
+ if (!isValidIsoInstant(r.at)) p.push('at이 ISO instant가 아님')
151
+ if (r.lifecycle !== null && typeof r.lifecycle !== 'string') p.push('lifecycle은 null이거나 문자열')
152
+ if (r.prompt_sha256 !== null && typeof r.prompt_sha256 !== 'string') p.push('prompt_sha256는 null이거나 문자열')
153
+
154
+ if (r.outcome !== null && (typeof r.outcome !== 'string' || !OUTCOMES.includes(r.outcome)))
155
+ p.push(`outcome 부적합: ${String(r.outcome)}`)
156
+
157
+ // 🔴 `attempt-opened`는 결과를 알 수 없는 시점이다 — 결과 필드가 채워져 있으면 순서가 뒤집힌 기록이다.
158
+ if (r.event === 'attempt-opened') {
159
+ for (const k of ['outcome', 'lifecycle'] as const) if (r[k] !== null) p.push(`attempt-opened인데 ${k}가 채워져 있음`)
160
+ }
161
+
162
+ // 🔴 `attempt-closed`는 판정이 끝난 시점이다 — 최소한 outcome·lifecycle은 있어야 한다(design 리뷰 observation).
163
+ // 이게 없으면 outcome=null인 closed 행이 통과해 "완료됐지만 판정 불명"이라는 자기모순 상태가 원장에 남는다.
164
+ if (r.event === 'attempt-closed') {
165
+ if (r.outcome === null) p.push('attempt-closed인데 outcome이 null')
166
+ if (r.lifecycle === null) p.push('attempt-closed인데 lifecycle이 null')
167
+ }
168
+ return p
169
+ }
170
+
171
+ export interface ParsedLedger {
172
+ rows: LedgerRow[]
173
+ /** 파싱·검증 문제. 비어 있지 않으면 손상 — 조용히 건너뛰지 않는다(D5). */
174
+ problems: string[]
175
+ }
176
+
177
+ /** 원장 본문 파싱(순수). 빈 줄은 무시하고, 파싱 불가·형식 위반은 problems로 드러낸다. */
178
+ export function parseLedger(content: string): ParsedLedger {
179
+ const rows: LedgerRow[] = []
180
+ const problems: string[] = []
181
+ const lines = content.split('\n')
182
+ const seen = new Set<string>()
183
+ lines.forEach((line, i) => {
184
+ if (line.trim() === '') return
185
+ let raw: unknown
186
+ try {
187
+ raw = JSON.parse(line)
188
+ } catch {
189
+ problems.push(`line ${i + 1}: JSON 파싱 실패`)
190
+ return
191
+ }
192
+ const ps = ledgerRowProblems(raw)
193
+ if (ps.length) {
194
+ problems.push(...ps.map((m) => `line ${i + 1}: ${m}`))
195
+ return
196
+ }
197
+ const row = raw as LedgerRow
198
+ const key = ledgerRowKey(row)
199
+ if (seen.has(key)) problems.push(`line ${i + 1}: 자연키 중복(${row.series_id} attempt=${row.attempt} ${row.event})`)
200
+ seen.add(key)
201
+ rows.push(row)
202
+ })
203
+ return { rows, problems }
204
+ }
205
+
206
+ /** 멱등 append 결과. `appended`=신규 기록, `duplicate`=동일 내용 재기록(no-op), `conflict`=같은 키 다른 내용(fail-closed). */
207
+ export type AppendOutcome = 'appended' | 'duplicate' | 'conflict'
208
+
209
+ export interface AppendResult {
210
+ outcome: AppendOutcome
211
+ /** append 후 본문. `duplicate`·`conflict`면 입력과 동일하다(쓰기 금지). */
212
+ content: string
213
+ problems: string[]
214
+ }
215
+
216
+ /**
217
+ * 멱등 append(순수 — 새 본문을 **반환**만 한다. 쓰기는 호출부).
218
+ *
219
+ * - 같은 자연키 + 동일 내용 → `duplicate`(행 수 불변). crash 후 재실행이 중복을 만들지 않는다.
220
+ * - 같은 자연키 + 다른 내용 → `conflict`. **append하지도 덮지도 않는다.** 조용한 덮어쓰기는
221
+ * append-only 원장의 신뢰를 무너뜨린다.
222
+ * - 기존 본문이 손상이면 그 문제를 그대로 올리고 append하지 않는다.
223
+ */
224
+ export function appendLedgerRow(existingContent: string, row: LedgerRow): AppendResult {
225
+ const rowProblems = ledgerRowProblems(row)
226
+ if (rowProblems.length) return { outcome: 'conflict', content: existingContent, problems: rowProblems }
227
+
228
+ const parsed = parseLedger(existingContent)
229
+ if (parsed.problems.length) return { outcome: 'conflict', content: existingContent, problems: parsed.problems }
230
+
231
+ const key = ledgerRowKey(row)
232
+ const prior = parsed.rows.find((r) => ledgerRowKey(r) === key)
233
+ if (prior) {
234
+ const same = serializeLedgerRow(prior) === serializeLedgerRow(row)
235
+ return same
236
+ ? { outcome: 'duplicate', content: existingContent, problems: [] }
237
+ : {
238
+ outcome: 'conflict',
239
+ content: existingContent,
240
+ problems: [`같은 자연키의 기존 행과 내용이 다름(${row.series_id} attempt=${row.attempt} ${row.event}) — 덮어쓰지 않는다`],
241
+ }
242
+ }
243
+
244
+ const base = existingContent === '' || existingContent.endsWith('\n') ? existingContent : `${existingContent}\n`
245
+ return { outcome: 'appended', content: base + serializeLedgerRow(row), problems: [] }
246
+ }
247
+
248
+ /**
249
+ * `attempt-opened`만 있고 대응하는 `attempt-closed`가 없는 attempt (요구사항 #1).
250
+ *
251
+ * 이것이 곧 **"예산은 깎였는데 완료되지 않은 호출"**이다 — 외부 호출 실패나 응답 처리 실패로
252
+ * 아카이브도 측정 로그도 남지 않은 시도. 소비자 저장소에서 실측 1건(attempts 4 vs 아카이브 3)이 있었다.
253
+ */
254
+ export function unclosedAttempts(rows: readonly LedgerRow[]): LedgerRow[] {
255
+ const closed = new Set(rows.filter((r) => r.event === 'attempt-closed').map((r) => [r.series_id, String(r.attempt)].join(KEY_SEP)))
256
+ return rows.filter((r) => r.event === 'attempt-opened' && !closed.has([r.series_id, String(r.attempt)].join(KEY_SEP)))
257
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 현재 리뷰의 **semantic identity** 계산 (REQ-2026-052 phase-2).
3
+ *
4
+ * 왜 필요한가: pre-call 원장 커밋(DEC-A)이 매 라운드 HEAD·인덱스를 바꾸므로, approval binding
5
+ * (`reviewBaseSha`/`reviewTree`)은 라운드마다 값이 달라진다. 그런데 두 판정은 **같은 리뷰의 반복**을
6
+ * 감지해야 하므로 audit bookkeeping 변화에 흔들리면 안 된다:
7
+ * - blocked-review circuit breaker(무한 재리뷰 차단)
8
+ * - `last_review.compare_hash` · req:next G2(바인딩 신선도)
9
+ *
10
+ * 그래서 approval binding과 **분리된** semantic identity를 둔다: 리뷰 대상(design 문서·phase 코드)은
11
+ * 반영하되, 티켓의 `responses/` audit 산출물(ledger·approvals·아카이브·close-proof·codex-response·
12
+ * preview)의 변화는 **무시**한다.
13
+ *
14
+ * 🔴 **`responses/` 전체를 제외한다**(design-r03-delta P1): 처음엔 `review-ledger.jsonl` 한 줄만
15
+ * 제외했으나, evidence-finalize가 approvals·아카이브를 커밋하면 identity가 바뀌어 방금 승인한 리뷰를
16
+ * req:next G2가 stale로 오판했다(요구 #4 위반). `responses/`는 **순수 audit**이고 리뷰 대상은 절대
17
+ * 그 안에 없다(design 문서=티켓 루트 `0N-*.md`, phase 코드=`workflow/` 밖). 따라서 `responses/`를
18
+ * 통째로 제외해도 리뷰 대상 손실 없이 pre-call 커밋·evidence-finalize 양쪽에 identity가 불변이다.
19
+ *
20
+ * 🔴 **`state.json`도 같은 이유로 제외한다**(REQ-2026-057). durable state checkpoint가 승인 상태를
21
+ * 커밋하면서 **인덱스의 `state.json` 항목**을 갱신하는데, 그것이 identity에 잡히면 방금 승인한 리뷰가
22
+ * 다시 stale로 오판된다 — `responses/`에서 이미 한 번 겪은 결함과 같은 형태다.
23
+ * `state.json`은 **도구가 쓰는 작업 상태**이고 리뷰 대상이 아니다(사람이 리뷰받는 것은 설계 문서와
24
+ * staged 코드다). 제외해도 승인 바인딩(D9 staged tree == approved tree)은 그대로이므로 방어가 약해지지
25
+ * 않는다 — identity는 "같은 리뷰의 반복인가"를 볼 뿐 승인 근거가 아니다.
26
+ *
27
+ * 🔴 **읽기 전용**: `git ls-files -s`만 쓴다. `git write-tree`는 object DB에 tree를 쓰므로 금지
28
+ * (`captureIndexHash`와 같은 기법 — req:next가 재계산할 수 있어야 한다).
29
+ *
30
+ * 인터페이스는 이 함수 하나. git 필터링·정렬·hash 규칙을 전부 이 모듈에 숨긴다.
31
+ * 호출자: `review-codex`(생성) · `req:next`(G2 재계산) · 테스트.
32
+ */
33
+ import { createHash } from 'node:crypto'
34
+
35
+ /** `git ...` 실행 경계(review-codex의 `GitFn`과 호환 — 주입 가능·테스트용). */
36
+ export type GitFn = (args: string[]) => string
37
+
38
+ /** `git ls-files -s` 한 줄에서 경로(탭 뒤)를 뽑는다. 형식: `<mode> <oid> <stage>\t<path>`. */
39
+ function pathOfLsFilesLine(line: string): string | null {
40
+ const tab = line.indexOf('\t')
41
+ return tab < 0 ? null : line.slice(tab + 1)
42
+ }
43
+
44
+ /**
45
+ * 현재 리뷰의 semantic identity(hex SHA256).
46
+ *
47
+ * = SHA256( 정렬된 `git ls-files -s` 줄들 중, 경로가 `<ticketRel>/responses/` 아래이거나
48
+ * 정확히 `<ticketRel>/state.json`인 줄을 제외 ).
49
+ *
50
+ * 원장·approvals·아카이브·작업 상태가 untracked/modified/committed 어느 상태든 제외되므로 identity가
51
+ * 그 변화에 불변이다. 리뷰 대상(문서·코드)과 그 밖의 non-audit 변경은 반영된다.
52
+ */
53
+ export function computeReviewSemanticIdentity(ticketRel: string, gitFn: GitFn): string {
54
+ const normTicket = ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')
55
+ // 🔴 ticketRel 경계가 애매하면(빈 값) fail-closed — 잘못된 접두사로 무언가를 조용히 제외하지 않는다.
56
+ if (normTicket === '') throw new Error('computeReviewSemanticIdentity: ticketRel이 비어 있음(제외 경계 불명 — fail-closed)')
57
+ const responsesPrefix = `${normTicket}/responses/` // 🔴 정확히 이 티켓의 responses/ 하위만. 다른 workflow 파일·문서·코드 미제외.
58
+ // 🔴 `state.json`은 **정확 일치**로만 제외한다(REQ-2026-057). 접두사 매칭으로 넓히면
59
+ // `state.json.bak` 같은 사용자 파일까지 조용히 사라진다.
60
+ const statePath = `${normTicket}/state.json`
61
+ const lines = gitFn(['ls-files', '-s'])
62
+ .split('\n')
63
+ .map((l) => l.replace(/\r$/, ''))
64
+ .filter((l) => l.trim() !== '')
65
+ .filter((l) => {
66
+ const p = pathOfLsFilesLine(l)
67
+ // 🔴 경로를 못 뽑으면(malformed) **보수적으로 포함**한다 — 모호한 경로를 제외하지 않는다(constraint 3).
68
+ // 제외는 명확히 `<ticketRel>/responses/` 하위이거나 `<ticketRel>/state.json`일 때만.
69
+ return p === null ? true : !p.startsWith(responsesPrefix) && p !== statePath
70
+ })
71
+ return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
72
+ }
@@ -20,6 +20,14 @@ import { isUntracked } from './porcelain'
20
20
  /** 티켓 디렉터리 안의 순수 untracked 도구 산출물. 커밋된 적이 없고 승인 증거가 아니다. */
21
21
  export const TOOL_OUTPUT_BASENAMES = ['codex-response.json', '.review-preview.txt'] as const
22
22
 
23
+ /**
24
+ * 리뷰 원장(REQ-2026-051)의 티켓-상대 경로. `state.json`과 **같은 범주**다 — 워크플로가 리뷰 중에
25
+ * (attempt-opened/closed) `responses/` 아래에 쓰는 메타데이터이고, 승인 시점에 커밋된다.
26
+ * 🔴 exact 경로로만 허용한다(rename/카피는 아래 responses/ 규칙으로 여전히 차단) — `responses/**` 전체를
27
+ * scratch로 열면 승인 아카이브 변조 구멍이 된다(REQ-2026-012 D8).
28
+ */
29
+ export const REVIEW_LEDGER_RELNAME = 'responses/review-ledger.jsonl' as const
30
+
23
31
  /** 경로 정규화: 역슬래시→슬래시(호출부가 넘기는 repo-상대는 이미 `/`지만 방어), 후행 슬래시 제거. */
24
32
  function normDir(dirRel: string): string {
25
33
  return dirRel.replace(/\\/g, '/').replace(/\/+$/, '')
@@ -31,7 +39,12 @@ function normDir(dirRel: string): string {
31
39
  */
32
40
  export function reviewScratchPaths(ticketDirRel: string): string[] {
33
41
  const dir = normDir(ticketDirRel)
34
- return [`${dir}/${TOOL_OUTPUT_BASENAMES[0]}`, `${dir}/${TOOL_OUTPUT_BASENAMES[1]}`, `${dir}/state.json`]
42
+ return [
43
+ `${dir}/${TOOL_OUTPUT_BASENAMES[0]}`,
44
+ `${dir}/${TOOL_OUTPUT_BASENAMES[1]}`,
45
+ `${dir}/state.json`,
46
+ `${dir}/${REVIEW_LEDGER_RELNAME}`, // REQ-2026-051: 리뷰 중 append되는 원장(state.json과 동종). exact 경로만.
47
+ ]
35
48
  }
36
49
 
37
50
  /** `REQ-<4자리>-<숫자>` 디렉터리명인가(문자열 분해 — 정규식 보간 금지, 설계 D7). */
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 티켓 `state.json`의 **durable checkpoint** (REQ-2026-057).
3
+ *
4
+ * 존재 이유: 승인 증거(`responses/**`)는 커밋되는데 그 승인을 반영한 **작업 상태는 커밋되지 않아**,
5
+ * 티켓을 정상 완주해도 `state.json`이 dirty로 남는다. 그 결과 (1) 다음 `req:new`가 clean-tree 게이트에서
6
+ * 막히고(`lib/scratch.ts`의 `isToolOutputScratch`는 `state.json`을 **의도적으로** 제외한다),
7
+ * (2) 계약이 시키는 대로 그 변경을 버리면 커밋된 증거가 있는데도 `req:next`가 재리뷰를 요구한다.
8
+ * 남겨도 막히고 버려도 안 되는 상태를 없애려면 상태가 증거와 함께 Git에 남아야 한다.
9
+ *
10
+ * 🔴 **leaf 모듈이다.** `review-codex`·`req-commit` 양쪽이 값으로 import하므로 여기서 그것들을 값으로
11
+ * import하면 런타임 순환이 생긴다(`lib/scratch.ts`가 leaf인 이유와 같다). 상태 타입은
12
+ * `import type`(컴파일 시 소거)으로만 받는다.
13
+ *
14
+ * 🔴 **증거 커밋에 상태를 끼워 넣지 않는다**(설계 DEC-1). 그러려면 `req-commit`의 "`responses/` 외 staged
15
+ * 금지" 가드를 완화해야 하는데, 그 가드는 코드/state 누수를 막는 마지막 방어선이다. 대신 티켓
16
+ * `state.json` **한 경로만** 담는 자기 커밋을 낸다 — `precallCommitLedgerRow`와 같은 pathspec 관용구다.
17
+ *
18
+ * ⚠️ 원자성: 증거 커밋과 이 커밋 사이에서 중단되면 `state.json`이 dirty로 남는다. 그것은 **이 REQ 이전의
19
+ * 기존 동작**이므로 회귀가 아니고, 재실행(멱등)이나 다음 경계의 checkpoint가 흡수한다.
20
+ */
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+
24
+ /** `state.json` 직렬화의 **단일 지점**. `review-codex`의 `writeState`가 이 함수를 쓴다(포맷 드리프트 금지). */
25
+ export function serializeState(state: unknown): string {
26
+ return `${JSON.stringify(state, null, 2)}\n`
27
+ }
28
+
29
+ export interface StateCheckpointArgs {
30
+ /** 소비 저장소 루트(절대 경로). */
31
+ root: string
32
+ /** 티켓 디렉터리의 repo-상대 경로(예: `workflow/REQ-2026-057`). */
33
+ ticketRel: string
34
+ /** 대상 티켓 id. 디스크 상태의 `id`와 대조한다. */
35
+ ticketId: string
36
+ /** 호출자가 **방금 `writeState`로 기록한** 상태 객체. 디스크 내용과 바이트 대조한다. */
37
+ state: { id?: unknown }
38
+ /** 커밋 메시지에 들어갈 사유(예: `design 승인`, `phase phase-1-x 소비`). */
39
+ reason: string
40
+ /** git 실행기(호출부의 어댑터를 그대로 받는다 — 테스트가 주입 가능). */
41
+ gitFn: (args: string[]) => string
42
+ }
43
+
44
+ /**
45
+ * 티켓 `state.json`을 pathspec 커밋한다.
46
+ *
47
+ * @returns 커밋했으면 `true`, **변경이 없어 무동작이면 `false`**(멱등 — 빈 커밋을 만들지 않는다).
48
+ *
49
+ * fail-closed 조건(둘 다 커밋 없이 throw):
50
+ * - 디스크 내용이 `state`의 직렬화와 다르다 → 외부 편집·경쟁 쓰기. 도구가 쓴 값만 커밋한다.
51
+ * - 디스크 상태의 `id`가 `ticketId`와 다르다 → 다른 티켓 상태를 이 티켓 커밋에 싣지 않는다.
52
+ */
53
+ export function commitStateCheckpoint(args: StateCheckpointArgs): boolean {
54
+ const { root, ticketRel, ticketId, state, reason, gitFn } = args
55
+ const stateRel = `${ticketRel}/state.json`
56
+ const stateAbs = join(root, ...stateRel.split('/'))
57
+
58
+ // 멱등: 워킹트리·인덱스 어느 쪽에도 변화가 없으면 낼 커밋이 없다.
59
+ if (gitFn(['status', '--porcelain', '--', stateRel]).trim() === '') return false
60
+
61
+ if (!existsSync(stateAbs)) throw new Error(`state checkpoint 거부: ${stateRel} 이 없습니다.`)
62
+ const onDisk = readFileSync(stateAbs, 'utf8')
63
+ if (onDisk !== serializeState(state))
64
+ throw new Error(
65
+ `state checkpoint 거부: ${stateRel} 의 디스크 내용이 도구가 기록한 상태와 다릅니다(외부 편집·경쟁 쓰기 의심) — 커밋하지 않았습니다.`,
66
+ )
67
+
68
+ // `state`가 아니라 **디스크**의 id를 본다 — 커밋되는 것이 디스크 내용이기 때문이다.
69
+ // (위 바이트 대조를 통과했으므로 두 값은 같지만, 판정 대상을 커밋 대상과 일치시켜 둔다.)
70
+ const diskId = (JSON.parse(onDisk) as { id?: unknown }).id
71
+ if (diskId !== ticketId)
72
+ throw new Error(`state checkpoint 거부: ${stateRel} 의 id(${String(diskId)})가 대상 티켓(${ticketId})과 다릅니다.`)
73
+
74
+ // 🔴 pathspec 커밋 — 이 경로만. 사용자가 stage해 둔 코드/문서는 인덱스에 그대로 남는다.
75
+ gitFn(['add', '--', stateRel])
76
+ gitFn(['commit', '-m', `chore(${ticketId}): state checkpoint — ${reason}`, '--', stateRel])
77
+ return true
78
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:close — 레거시 완료 티켓 **마이그레이션 종결**(REQ-2026-053·DEC-M).
4
+ *
5
+ * close-proof/`phase_design_ref` regime **이전에** 완료·병합돼 dev-complete로 자기증명될 수 없는 durable
6
+ * 티켓을, 운영자 확인 후 `migrated-complete` close-proof 행으로 감사 가능하게 종결한다. 자격 판정은
7
+ * `lib/close-migrate`(순수)가, HEAD blob 수집·integrated 계산·durable 커밋은 이 CLI가 낸다.
8
+ *
9
+ * 🔴 **HEAD-committed 증거 + git ancestry만** 근거로 쓴다 — 워킹 state·미커밋 원장·추정 phase 목록 미사용.
10
+ * 🔴 완료성 증명 = **integrated**(티켓 매니페스트 커밋이 본선 조상). mainline ref는 **운영자 입력을 받지 않고**
11
+ * 신뢰된 ref로만 해소(origin/HEAD→origin/main→로컬 main). 미해소면 fail-closed.
12
+ * 🔴 기본 **dry-run**. `--run` 후에만 write. 새 행은 `reconstructed:true` + 비어있지 않은 evidence_basis.
13
+ * 쓰기 전 close-proof clean 가드(미커밋 close-proof를 HEAD 기반 쓰기가 덮지 않게). append-only·자연키 멱등.
14
+ *
15
+ * 사용: req:close <REQ> --migrate [--run] [--root <dir>]
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join, relative } from 'node:path'
19
+ import { pathToFileURL } from 'node:url'
20
+ import { loadConfig, packageRoot } from './lib/config'
21
+ import { createGitAdapter, safeSpawnSyncStatus, type GitAdapter } from './lib/adapters'
22
+ import { createEvidencePorts } from './lib/evidence-ports'
23
+ import {
24
+ isDurabilityRequired,
25
+ verifyCommittedEvidenceIntegrity,
26
+ validateManifest,
27
+ evidencedPhaseIdsFromManifest,
28
+ designHashFromManifest,
29
+ } from './lib/evidence'
30
+ import { parseCloseProof, appendCloseProofRow, closeProofPath } from './lib/close-proof'
31
+ import { planMigrationClose, type MigrationFacts } from './lib/close-migrate'
32
+
33
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
34
+ function git(args: string[]): string {
35
+ return gitAdapter.exec(args)
36
+ }
37
+
38
+ export interface Opts {
39
+ reqId: string | null
40
+ migrate: boolean
41
+ run: boolean
42
+ root: string | null
43
+ }
44
+
45
+ /** 인자 파싱(fail-closed): 값 누락·알 수 없는 옵션은 즉시 throw. mainline override는 **의도적으로 없다**(DEC-M3.7). */
46
+ export function parseArgs(argv: string[]): Opts {
47
+ const o: Opts = { reqId: null, migrate: false, run: false, root: null }
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i]
50
+ if (a === undefined) continue
51
+ if (a === '--') continue
52
+ else if (a === '--migrate') o.migrate = true
53
+ else if (a === '--run') o.run = true
54
+ else if (a === '--root') {
55
+ const v = argv[++i]
56
+ if (v === undefined) throw new Error('--root 값 필요')
57
+ o.root = v
58
+ } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
59
+ else o.reqId = a
60
+ }
61
+ return o
62
+ }
63
+
64
+ /**
65
+ * 🔴 mainline ref를 **신뢰된 소스로만** 해소한다(DEC-M3.7·r02 P1). 운영자 입력을 받지 않는다 —
66
+ * 임의 feature/HEAD ref로 integrated를 통과시키는 우회를 막는다. 순서:
67
+ * ① origin/HEAD(원격이 선언한 기본 브랜치) → ② origin/main·master → ③ 로컬 main·master. 없으면 null(fail-closed).
68
+ */
69
+ export function resolveMainline(gitFn: (a: string[]) => string): string | null {
70
+ try {
71
+ const sym = gitFn(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']).trim()
72
+ if (sym) return sym.replace(/^refs\/remotes\//, '') // 예: refs/remotes/origin/main → origin/main
73
+ } catch {
74
+ /* origin/HEAD 없음 — 다음 후보로 */
75
+ }
76
+ for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
77
+ try {
78
+ if (gitFn(['rev-parse', '--verify', '--quiet', ref]).trim()) return ref
79
+ } catch {
80
+ /* 해당 ref 없음 */
81
+ }
82
+ }
83
+ return null
84
+ }
85
+
86
+ /** `git merge-base --is-ancestor a b` — a가 b의 조상이면 true(exit 0), 아니면 false(exit 1), 그 외는 throw. */
87
+ export function isAncestor(root: string, a: string, b: string): boolean {
88
+ const res = safeSpawnSyncStatus('git', ['merge-base', '--is-ancestor', a, b], { cwd: root })
89
+ if (res.status === 0) return true
90
+ if (res.status === 1) return false
91
+ throw new Error(`git merge-base --is-ancestor 실패(status=${res.status ?? 'null'}): ${res.stderr.trim()}`)
92
+ }
93
+
94
+ /** HEAD state.json 본문에서 커밋된 phase 계획 id(`phases[].id`)를 뽑는다(파싱 불가·부재 → []). r02 P1 완료성 기준. */
95
+ export function committedPlannedPhaseIds(stateText: string | null): string[] {
96
+ if (!stateText) return []
97
+ let raw: unknown
98
+ try {
99
+ raw = JSON.parse(stateText)
100
+ } catch {
101
+ return []
102
+ }
103
+ const phases = (raw as { phases?: unknown }).phases
104
+ if (!Array.isArray(phases)) return []
105
+ return phases
106
+ .map((p) => (p && typeof p === 'object' ? (p as { id?: unknown }).id : undefined))
107
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
108
+ }
109
+
110
+ export function main(argv: string[] = process.argv.slice(2)): void {
111
+ const o = parseArgs(argv)
112
+ if (!o.reqId) throw new Error('REQ 필요 (예: req:close 2026-049 --migrate)')
113
+ if (!o.migrate) throw new Error('현재 --migrate 모드만 지원합니다 (예: req:close 2026-049 --migrate --run)')
114
+ const cfg = loadConfig({ root: o.root })
115
+ gitAdapter = createGitAdapter(cfg.root)
116
+ const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
117
+ const ticketDir = join(cfg.workflowDirAbs, reqId)
118
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
119
+ const manifestRel = `${ticketRel}/responses/approvals.jsonl`
120
+
121
+ // 1. HEAD 사실 수집(read-only). intake와 같은 포트·함수.
122
+ const ports = createEvidencePorts(cfg.root, `${ticketRel}/responses`)
123
+ const stateText = ports.headText(`${ticketRel}/state.json`)
124
+ const durabilityRequired = isDurabilityRequired(stateText)
125
+ const manifestText = ports.headText(manifestRel)
126
+ const closeText = ports.headText(closeProofPath(ticketRel))
127
+ const evidencedPhaseIdsAll = manifestText ? evidencedPhaseIdsFromManifest(manifestText) : []
128
+ const manifestProblems = manifestText ? validateManifest(manifestText, { ticketRel, validPhaseIds: evidencedPhaseIdsAll }) : []
129
+ const closeParsed = closeText ? parseCloseProof(closeText) : { rows: [], problems: [] }
130
+ const committedDesignRef = manifestText ? designHashFromManifest(manifestText) : null
131
+ const evidencedPhaseIdsBound = manifestText ? evidencedPhaseIdsFromManifest(manifestText, committedDesignRef) : []
132
+ const integrity = verifyCommittedEvidenceIntegrity({ ticketRel, manifestText, ports })
133
+
134
+ // 2. integrated(완료성 증명) — 티켓 매니페스트를 마지막으로 수정한 HEAD 커밋이 mainline의 조상인가.
135
+ const mainline = resolveMainline((a) => git(a))
136
+ if (!mainline)
137
+ throw new Error(
138
+ `${reqId}: mainline(main/origin/main)을 결정할 수 없어 integrated(완료성)를 판정할 수 없습니다 — fail-closed. ` +
139
+ `본선 브랜치가 존재하는 저장소에서 실행하세요.`,
140
+ )
141
+ let lastManifestCommit = ''
142
+ try {
143
+ lastManifestCommit = git(['rev-list', '-1', 'HEAD', '--', manifestRel]).trim()
144
+ } catch {
145
+ lastManifestCommit = ''
146
+ }
147
+ const integrated = !!lastManifestCommit && isAncestor(cfg.root, lastManifestCommit, mainline)
148
+
149
+ // 3. 순수 판정.
150
+ const facts: MigrationFacts = {
151
+ ticketId: reqId,
152
+ ticketRel,
153
+ durabilityRequired,
154
+ manifestText,
155
+ manifestProblems,
156
+ closeProblems: closeParsed.problems,
157
+ closeRows: closeParsed.rows,
158
+ evidenceIntegrityProblems: integrity.problems,
159
+ committedDesignRef,
160
+ evidencedPhaseIdsAll,
161
+ evidencedPhaseIdsBound,
162
+ committedPlannedPhaseIds: committedPlannedPhaseIds(stateText),
163
+ integrated,
164
+ nowIso: new Date().toISOString(),
165
+ evidenceBasis: [manifestRel], // 마이그레이션 근거: 티켓 매니페스트(design_hash·phase_design_ref·archive_inventory의 단일 출처).
166
+ }
167
+ const plan = planMigrationClose(facts)
168
+
169
+ if (plan.kind === 'refuse')
170
+ throw new Error(`${reqId} 마이그레이션 종결 불가: ${plan.reason}\n → ${plan.hint}`)
171
+ if (plan.kind === 'noop') {
172
+ console.log(`[req:close] ${reqId} 이미 종결(${plan.existingState}) — no-op(write 0).`)
173
+ return
174
+ }
175
+
176
+ // plan.kind === 'stamp'
177
+ console.log(`[req:close] ${reqId} 마이그레이션 종결(migrated-complete) 계획 (integrated=본선 조상, mainline=${mainline}):`)
178
+ console.log(
179
+ ` phase_inventory=[${plan.row.phase_inventory?.join(', ')}] design_ref=${(plan.row.design_ref ?? '').slice(0, 12)} ` +
180
+ `evidence_basis=[${plan.row.evidence_basis?.join(', ')}]`,
181
+ )
182
+ if (!o.run) {
183
+ console.log('[req:close] DRY-RUN — write 없음(--run 시 실행).')
184
+ return
185
+ }
186
+
187
+ // 🔴 쓰기 전 clean 가드: close-proof가 HEAD와 동일해야(미커밋 close-proof를 HEAD 기반 쓰기가 잃지 않게).
188
+ const cpRel = closeProofPath(ticketRel)
189
+ const cpDirty = git(['status', '--porcelain', '--', cpRel]).trim()
190
+ if (cpDirty)
191
+ throw new Error(
192
+ `${reqId}: close-proof(${cpRel})에 미커밋 변경이 있어 종결을 거부합니다(fail-closed) — HEAD 기반 쓰기가 미커밋 행을 덮을 수 있습니다.\n` +
193
+ ` 먼저 미커밋 close-proof 변경을 커밋/정리한 뒤 다시 실행하세요.`,
194
+ )
195
+
196
+ const abs = join(cfg.root, cpRel)
197
+ const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
198
+ const res = appendCloseProofRow(existing, plan.row)
199
+ if (res.outcome === 'conflict') throw new Error(`close proof 충돌(fail-closed): ${res.problems.join('; ')}`)
200
+ if (res.outcome === 'duplicate') {
201
+ console.log('[req:close] 동일 migrated-complete 행이 이미 존재 — no-op(멱등).')
202
+ return
203
+ }
204
+ mkdirSync(dirname(abs), { recursive: true })
205
+ writeFileSync(abs, res.content, 'utf8')
206
+ git(['add', '--', cpRel])
207
+ git(['commit', '-m', `chore(${reqId}): migrated-complete close proof (레거시 마이그레이션 종결·REQ-2026-053)`, '--', cpRel])
208
+ console.log(`[req:close] ✅ ${reqId} migrated-complete durable commit(reconstructed:true·evidence_basis).`)
209
+ }
210
+
211
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). */
212
+ export function runCli(argv: string[]): void {
213
+ try {
214
+ main(argv)
215
+ } catch (err) {
216
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
217
+ process.exitCode = 1
218
+ }
219
+ }
220
+
221
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
222
+ if (isMain) main()