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,323 @@
1
+ /**
2
+ * 커밋되는 **티켓 lifecycle close proof** (REQ-2026-052).
3
+ *
4
+ * 왜 원장과 별도인가: `review-ledger.jsonl`(B1)은 **attempt 단위**(opened/closed) 감사다. close proof는
5
+ * **티켓/series 단위 lifecycle 전이**(replace·human-resolution 종결, 개발 완료)를 담는다. 요구가 "사람이
6
+ * 실행하는 종결 경로는 ledger와 close proof를 **함께** 내구화"라 명시하므로 둘을 섞지 않는다.
7
+ *
8
+ * 🔴 **모든 판정은 HEAD-committed 아티팩트만** 입력이다 — 워킹 state·워킹 승인은 절대 쓰지 않는다.
9
+ * scratch state가 사라져도 HEAD만으로 티켓 상태·req:new 허용·재구성 여부를 판별할 수 있어야 한다.
10
+ *
11
+ * 🔴 **prompt·응답 본문·민감 데이터를 저장하지 않는다.** 허용키 화이트리스트가 자리 자체를 막는다.
12
+ *
13
+ * 순수 모듈 — fs·git을 모른다. 부작용은 호출부가 낸다(`lib/evidence`·`lib/review-ledger`와 같은 태도).
14
+ */
15
+ import { isValidIsoInstant } from './evidence'
16
+
17
+ /** close proof 파일의 basename. `approvals.jsonl`·`review-ledger.jsonl`과 같은 `responses/` 디렉터리. */
18
+ export const CLOSE_PROOF_BASENAME = 'ticket-close.jsonl'
19
+
20
+ /** 티켓 `responses/` 기준 close proof의 repo-상대 경로. */
21
+ export function closeProofPath(ticketRel: string): string {
22
+ return `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/${CLOSE_PROOF_BASENAME}`
23
+ }
24
+
25
+ /**
26
+ * lifecycle 전이 종류(DEC-B).
27
+ * - `series-terminal`: 사람이 한 series를 replace/human-resolution으로 종결(원장과 함께 커밋).
28
+ * - `dev-complete`: 모든 phase 증거가 durable해진 시점(마지막 evidence-finalize 직후 방출).
29
+ * - `migrated-complete`(REQ-2026-053): close-proof/`phase_design_ref` regime **이전에** 완료·병합돼
30
+ * dev-complete로 자기증명될 수 없는 레거시 티켓을, 운영자가 완료 확인 후 남기는 **사후 마이그레이션 종결**
31
+ * (`reconstructed:true` 강제 — self-verifying dev-complete와 구별). `req:close --migrate`가 발행.
32
+ *
33
+ * 🔴 `integrated`는 여기 없다 — git ancestry로 관측하는 오버레이이지 커밋되는 전이가 아니다(DEC-B).
34
+ */
35
+ export type CloseProofEvent = 'series-terminal' | 'dev-complete' | 'migrated-complete'
36
+
37
+ /** series-terminal의 종결 사유(사람 결정). */
38
+ export type TerminalResolution = 'replace' | 'human-resolution'
39
+
40
+ export interface CloseProofRow {
41
+ ticket_id: string
42
+ event: CloseProofEvent
43
+ /** `series-terminal`일 때 그 series의 id. `dev-complete`이면 null. */
44
+ series_id: string | null
45
+ /** `series-terminal`일 때 종결 사유. `dev-complete`이면 null. */
46
+ resolution: TerminalResolution | null
47
+ /**
48
+ * 🔴 `dev-complete`일 때 **완료 대상으로 확정된 phase ID 목록**(정렬·중복 없음, DEC-B2). `series-terminal`이면 null.
49
+ * 이것이 "무엇이 완료인가"의 정본이다 — 미래 HEAD verifier는 runtime `state.phases`가 아니라 이 목록을 본다.
50
+ */
51
+ phase_inventory: string[] | null
52
+ /**
53
+ * 🔴 `dev-complete`일 때 이 inventory가 묶인 **design 승인 참조**(= 발행 시점 committed design 승인의 design_hash).
54
+ * `series-terminal`이면 null. design 재승인으로 inventory가 달라지면 옛 design_ref와 섞인 proof는 무효(DEC-B2).
55
+ */
56
+ design_ref: string | null
57
+ at: string
58
+ /** 사후 복원 행인지(DEC-D). 복원본을 원본으로 위장하지 않는다. */
59
+ reconstructed: boolean
60
+ /**
61
+ * 복원 행일 때 그 사실을 유도한 근거(어떤 아카이브·매니페스트). 원본(비복원) 행이면 null.
62
+ * 🔴 본문이 아니라 **경로/식별자 목록**이다 — 민감 데이터를 담지 않는다.
63
+ */
64
+ evidence_basis: string[] | null
65
+ }
66
+
67
+ /** 직렬화 키 순서(고정 — deterministic) + 허용키 화이트리스트(여기 없는 top-level 키 = 오염 → 거부). */
68
+ export const CLOSE_PROOF_KEYS = [
69
+ 'ticket_id',
70
+ 'event',
71
+ 'series_id',
72
+ 'resolution',
73
+ 'phase_inventory',
74
+ 'design_ref',
75
+ 'at',
76
+ 'reconstructed',
77
+ 'evidence_basis',
78
+ ] as const
79
+
80
+ const EVENTS: readonly string[] = ['series-terminal', 'dev-complete', 'migrated-complete']
81
+ const RESOLUTIONS: readonly string[] = ['replace', 'human-resolution']
82
+
83
+ /** 한 줄 직렬화(JSONL): 고정 키 순서 JSON + 끝 개행. */
84
+ export function serializeCloseProofRow(row: CloseProofRow): string {
85
+ const o: Record<string, unknown> = {}
86
+ for (const k of CLOSE_PROOF_KEYS) o[k] = row[k]
87
+ return `${JSON.stringify(o)}\n`
88
+ }
89
+
90
+ /**
91
+ * 자연키(멱등 판정 단위). `series-terminal`은 `(ticket, event, series)`, `dev-complete`은 `(ticket, event)`.
92
+ * 구분자는 US(0x1F) — 식별자에 나타날 수 없는 제어문자(원장과 동일 기법, 소스에 리터럴 금지).
93
+ */
94
+ const KEY_SEP = String.fromCharCode(31)
95
+ /**
96
+ * 자연키(멱등·supersede 단위).
97
+ * - `series-terminal`: `(ticket, event, series_id)` — series별 1행.
98
+ * - `dev-complete`: `(ticket, event, design_ref)` 🔴 **design_ref로 키잉한다**(phase-3a r02 P1). design 재승인으로
99
+ * design_ref가 바뀌면 **다른 자연키**가 되어 새 dev-complete 행이 append-only로 추가된다(옛 행은 supersede —
100
+ * 삭제하지 않고, verifier가 현재 design_ref에 맞는 행만 고른다). design_ref로 키잉하지 않으면 재완료가
101
+ * 자연키 충돌(conflict)로 영구 실패한다.
102
+ */
103
+ export function closeProofRowKey(row: Pick<CloseProofRow, 'ticket_id' | 'event' | 'series_id' | 'design_ref'>): string {
104
+ // dev-complete: design_ref(재승인 supersede). series-terminal: series_id(series별 1행).
105
+ // migrated-complete(REQ-2026-053): discriminator 없음 — 티켓당 1행(마이그레이션은 일회성).
106
+ const discriminator =
107
+ row.event === 'dev-complete' ? (row.design_ref ?? '') : row.event === 'series-terminal' ? (row.series_id ?? '') : ''
108
+ return [row.ticket_id, row.event, discriminator].join(KEY_SEP)
109
+ }
110
+
111
+ /** `dev-complete`·`migrated-complete` 공유: phase_inventory 형식 검증(비지 않은 문자열·정렬·중복 없음). */
112
+ function phaseInventoryProblems(v: unknown, eventLabel: string): string[] {
113
+ const p: string[] = []
114
+ if (!Array.isArray(v)) {
115
+ p.push(`${eventLabel}인데 phase_inventory가 배열이 아님`)
116
+ return p
117
+ }
118
+ if (v.length === 0) p.push(`${eventLabel}인데 phase_inventory가 비어 있음`)
119
+ if (!v.every((x) => typeof x === 'string' && x !== '')) p.push('phase_inventory 항목은 비지 않은 문자열')
120
+ const sorted = [...v].sort()
121
+ if (JSON.stringify(sorted) !== JSON.stringify(v)) p.push('phase_inventory가 정렬돼 있지 않음')
122
+ if (new Set(v).size !== v.length) p.push('phase_inventory에 중복 있음')
123
+ return p
124
+ }
125
+
126
+ /**
127
+ * 행 하나의 형식 문제 목록(순수). 빈 배열 = 정상.
128
+ * 🔴 모르는 top-level 키는 거부(주입·오염 방어). `dev-complete`·`migrated-complete`은 series/resolution이 null이어야 한다.
129
+ */
130
+ export function closeProofRowProblems(raw: unknown): string[] {
131
+ const p: string[] = []
132
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['객체가 아님']
133
+ const r = raw as Record<string, unknown>
134
+
135
+ const allowed = new Set<string>(CLOSE_PROOF_KEYS)
136
+ for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`알 수 없는 키: ${k}`)
137
+ for (const k of CLOSE_PROOF_KEYS) if (!(k in r)) p.push(`필수 키 누락: ${k}`)
138
+ if (p.length) return p
139
+
140
+ if (typeof r.ticket_id !== 'string' || r.ticket_id === '') p.push('ticket_id가 비어 있음')
141
+ if (typeof r.event !== 'string' || !EVENTS.includes(r.event)) p.push(`event 부적합: ${String(r.event)}`)
142
+ if (typeof r.reconstructed !== 'boolean') p.push('reconstructed는 boolean')
143
+ if (!isValidIsoInstant(r.at)) p.push('at이 ISO instant가 아님')
144
+
145
+ if (r.event === 'series-terminal') {
146
+ if (typeof r.series_id !== 'string' || r.series_id === '') p.push('series-terminal인데 series_id가 비어 있음')
147
+ if (typeof r.resolution !== 'string' || !RESOLUTIONS.includes(r.resolution))
148
+ p.push(`series-terminal인데 resolution 부적합: ${String(r.resolution)}`)
149
+ if (r.phase_inventory !== null) p.push('series-terminal인데 phase_inventory가 null이 아님')
150
+ if (r.design_ref !== null) p.push('series-terminal인데 design_ref가 null이 아님')
151
+ } else if (r.event === 'dev-complete') {
152
+ if (r.series_id !== null) p.push('dev-complete인데 series_id가 null이 아님')
153
+ if (r.resolution !== null) p.push('dev-complete인데 resolution이 null이 아님')
154
+ // 🔴 dev-complete는 self-verifying — phase_inventory(정렬·중복 없음)와 design_ref가 필수(DEC-B2).
155
+ p.push(...phaseInventoryProblems(r.phase_inventory, 'dev-complete'))
156
+ if (typeof r.design_ref !== 'string' || r.design_ref === '') p.push('dev-complete인데 design_ref가 비어 있음')
157
+ } else if (r.event === 'migrated-complete') {
158
+ // 🔴 REQ-2026-053(DEC-M2): 운영자 확인 마이그레이션 종결. dev-complete와 같은 phase_inventory·design_ref를
159
+ // 담되 self-verifying이 아니라 **사후 스탬프**임을 스키마가 드러낸다 — reconstructed===true 강제.
160
+ if (r.series_id !== null) p.push('migrated-complete인데 series_id가 null이 아님')
161
+ if (r.resolution !== null) p.push('migrated-complete인데 resolution이 null이 아님')
162
+ p.push(...phaseInventoryProblems(r.phase_inventory, 'migrated-complete'))
163
+ if (typeof r.design_ref !== 'string' || r.design_ref === '') p.push('migrated-complete인데 design_ref가 비어 있음')
164
+ // 🔴 마이그레이션은 항상 사후 스탬프 — reconstructed:true 필수(evidence_basis 비-빔은 아래 공통 규칙이 강제).
165
+ if (r.reconstructed !== true) p.push('migrated-complete인데 reconstructed가 true가 아님(마이그레이션은 사후 스탬프)')
166
+ }
167
+
168
+ if (r.evidence_basis !== null) {
169
+ if (!Array.isArray(r.evidence_basis)) p.push('evidence_basis는 null이거나 배열')
170
+ else if (!r.evidence_basis.every((x) => typeof x === 'string')) p.push('evidence_basis 항목은 문자열')
171
+ }
172
+ // 🔴 복원 행이면 근거가 있어야 하고, 비복원 행이면 근거가 없어야 한다(원본과 복원의 명확한 구별).
173
+ if (r.reconstructed === true && (r.evidence_basis === null || (Array.isArray(r.evidence_basis) && r.evidence_basis.length === 0)))
174
+ p.push('reconstructed:true인데 evidence_basis가 비어 있음(근거 없는 복원 금지)')
175
+ if (r.reconstructed === false && r.evidence_basis !== null) p.push('원본 행(reconstructed:false)인데 evidence_basis가 있음')
176
+ return p
177
+ }
178
+
179
+ export interface ParsedCloseProof {
180
+ rows: CloseProofRow[]
181
+ problems: string[]
182
+ }
183
+
184
+ /** close proof 본문 파싱(순수). 빈 줄 무시, 파싱 불가·형식 위반·자연키 중복은 problems로 드러낸다. */
185
+ export function parseCloseProof(content: string): ParsedCloseProof {
186
+ const rows: CloseProofRow[] = []
187
+ const problems: string[] = []
188
+ const seen = new Set<string>()
189
+ content.split('\n').forEach((line, i) => {
190
+ if (line.trim() === '') return
191
+ let raw: unknown
192
+ try {
193
+ raw = JSON.parse(line)
194
+ } catch {
195
+ problems.push(`line ${i + 1}: JSON 파싱 실패`)
196
+ return
197
+ }
198
+ const ps = closeProofRowProblems(raw)
199
+ if (ps.length) {
200
+ problems.push(...ps.map((m) => `line ${i + 1}: ${m}`))
201
+ return
202
+ }
203
+ const row = raw as CloseProofRow
204
+ const key = closeProofRowKey(row)
205
+ if (seen.has(key)) problems.push(`line ${i + 1}: 자연키 중복(${row.event} ${row.series_id ?? ''})`)
206
+ seen.add(key)
207
+ rows.push(row)
208
+ })
209
+ return { rows, problems }
210
+ }
211
+
212
+ export type CloseProofAppendOutcome = 'appended' | 'duplicate' | 'conflict'
213
+ export interface CloseProofAppendResult {
214
+ outcome: CloseProofAppendOutcome
215
+ content: string
216
+ problems: string[]
217
+ }
218
+
219
+ /**
220
+ * 멱등 append(순수 — 새 본문 반환만, 쓰기는 호출부). 원장과 같은 규칙(DEC-A3):
221
+ * 같은 자연키+동일 내용 → duplicate(no-op), 같은 자연키+다른 내용 → conflict(덮지 않음·fail-closed).
222
+ * 기존 본문 손상이면 그대로 올리고 append하지 않는다(D5 태도).
223
+ */
224
+ export function appendCloseProofRow(existingContent: string, row: CloseProofRow): CloseProofAppendResult {
225
+ const rowProblems = closeProofRowProblems(row)
226
+ if (rowProblems.length) return { outcome: 'conflict', content: existingContent, problems: rowProblems }
227
+
228
+ const parsed = parseCloseProof(existingContent)
229
+ if (parsed.problems.length) return { outcome: 'conflict', content: existingContent, problems: parsed.problems }
230
+
231
+ const key = closeProofRowKey(row)
232
+ const prior = parsed.rows.find((r) => closeProofRowKey(r) === key)
233
+ if (prior) {
234
+ const same = serializeCloseProofRow(prior) === serializeCloseProofRow(row)
235
+ return same
236
+ ? { outcome: 'duplicate', content: existingContent, problems: [] }
237
+ : { outcome: 'conflict', content: existingContent, problems: [`같은 자연키의 기존 행과 내용이 다름(${row.event}) — 덮어쓰지 않는다`] }
238
+ }
239
+ const base = existingContent === '' || existingContent.endsWith('\n') ? existingContent : `${existingContent}\n`
240
+ return { outcome: 'appended', content: base + serializeCloseProofRow(row), problems: [] }
241
+ }
242
+
243
+ // ───────────────────────────────── 상태 파생(DEC-B) — 순수 ──
244
+
245
+ /** 순수 파생기의 입력. 🔴 전부 **HEAD-committed 사실**만. 호출부가 포트로 채운다. runtime state 절대 미사용(DEC-B4). */
246
+ export interface CloseStateInput {
247
+ /** HEAD scaffold state.json에 durability marker가 있는가(`isDurabilityRequired`). false면 legacy. */
248
+ durabilityRequired: boolean
249
+ /** HEAD close proof를 파싱한 행들(없으면 []). */
250
+ closeProofRows: readonly CloseProofRow[]
251
+ /**
252
+ * HEAD durable 원장이 이 티켓에 **approved인 attempt-closed**를 담고 있는가.
253
+ * needs-recovery 판정 입력 — 원장에 승인 흔적이 있는데 HEAD 증거가 불완전하면 recovery 필요.
254
+ */
255
+ ledgerHasApprovedClose: boolean
256
+ /** HEAD 증거(approvals·아카이브)가 그 승인에 대해 완비됐는가(`verifyCommittedDesignEvidence` 계열). */
257
+ committedEvidenceComplete: boolean
258
+ /**
259
+ * 🔴 HEAD `approvals.jsonl`에서 **현재 committed design_ref에 결속된**(`phase_design_ref === committedDesignRef`)
260
+ * phase evidence의 phase ID 집합. dev-complete self-verify 입력(DEC-B2/B5).
261
+ *
262
+ * 🔴 **design-bound 필터는 호출부(manifest 읽는 경계)가 이미 적용**한다 — 이 순수 판정기는 manifest를 파싱하지
263
+ * 않는 leaf라 필터를 여기 넣으면 모듈 경계가 깨진다(`evidencedPhaseIdsFromManifest(content, designRef)`가
264
+ * 필터 지점). 단순 phase_id 존재가 아니라 **결속된 증거만** 담겨야 D1 검토분이 D2 완료에 새지 않는다(phase-3a P1).
265
+ * dev-complete proof의 `phase_inventory`의 모든 phase가 이 집합에 있어야 dev-complete다.
266
+ */
267
+ evidencedPhaseIds: readonly string[]
268
+ /**
269
+ * 🔴 현재 HEAD의 committed design 승인 참조(design_hash). dev-complete proof의 `design_ref`와 일치해야 dev-complete다.
270
+ * design 재승인으로 값이 바뀌면 옛 design_ref proof는 무효(DEC-B2). 계산 불가면 null(→ dev-complete 아님).
271
+ */
272
+ committedDesignRef: string | null
273
+ }
274
+
275
+ /** 기본 상태(배타·완결). `migrated-complete`(REQ-2026-053)는 dev-complete 아래·needs-recovery 위의 비차단 종결. */
276
+ export type CloseBaseState = 'legacy' | 'series-terminal' | 'dev-complete' | 'migrated-complete' | 'needs-recovery' | 'developing'
277
+
278
+ /**
279
+ * 🔴 dev-complete self-verify(순수, DEC-B2). HEAD close proof의 dev-complete row가 **자기 완결적으로** 증명되는가:
280
+ * ① dev-complete row 존재 ② 그 row의 phase_inventory **모든 phase**가 HEAD 증거(evidencedPhaseIds)에 있음
281
+ * ③ row의 design_ref = 현재 committed design 참조. runtime state는 절대 안 본다.
282
+ */
283
+ export function isDevCompleteVerified(input: CloseStateInput): boolean {
284
+ if (input.committedDesignRef === null) return false
285
+ // 🔴 **현재 design_ref에 맞는** dev-complete 행을 고른다(phase-3a r02 P1). design 재승인 후 옛 design_ref
286
+ // 행은 무시(supersede)되고, 새 design_ref의 행이 있으면 그것으로 검증한다.
287
+ const dc = input.closeProofRows.find((r) => r.event === 'dev-complete' && r.design_ref === input.committedDesignRef)
288
+ if (!dc || !Array.isArray(dc.phase_inventory) || dc.phase_inventory.length === 0) return false
289
+ const evidenced = new Set(input.evidencedPhaseIds)
290
+ return dc.phase_inventory.every((p) => evidenced.has(p))
291
+ }
292
+
293
+ /**
294
+ * 기본 상태 파생(순수·배타·완결 — 항상 정확히 하나). 우선순위:
295
+ * `legacy` > `series-terminal` > `dev-complete` > `migrated-complete` > `needs-recovery` > `developing`(기본값).
296
+ *
297
+ * 🔴 워킹 state·워킹 승인을 절대 입력으로 받지 않는다(design-r01 P1·B4). `integrated`는 여기서 내지 않는다
298
+ * — git ancestry 오버레이라 순수 파생 밖이다(design-r02 P1).
299
+ */
300
+ export function deriveBaseState(input: CloseStateInput): CloseBaseState {
301
+ if (!input.durabilityRequired) return 'legacy'
302
+ const hasEvent = (e: CloseProofEvent): boolean => input.closeProofRows.some((r) => r.event === e)
303
+ if (hasEvent('series-terminal')) return 'series-terminal'
304
+ // 🔴 dev-complete는 self-verifying(DEC-B2): proof + committed evidence + design_ref로만 판정.
305
+ if (isDevCompleteVerified(input)) return 'dev-complete'
306
+ // 🔴 REQ-2026-053(DEC-M1): 마이그레이션 종결 — dev-complete **아래**(정상 완료가 이김), needs-recovery
307
+ // **위**(일단 마이그레이션되면 종결). req:close가 needs-recovery/corrupt엔 스탬프를 찍지 않으므로(DEC-M3)
308
+ // 이 우선순위가 recovery 필요를 가리지 않는다.
309
+ if (hasEvent('migrated-complete')) return 'migrated-complete'
310
+ // 승인 흔적(원장)은 있으나 HEAD 증거가 불완전 = 복구 필요.
311
+ if (input.ledgerHasApprovedClose && !input.committedEvidenceComplete) return 'needs-recovery'
312
+ return 'developing'
313
+ }
314
+
315
+ /** `reconstructed` 오버레이(순수·blob). close proof에 복원 행이 하나라도 있으면 true. */
316
+ export function isReconstructed(rows: readonly CloseProofRow[]): boolean {
317
+ return rows.some((r) => r.reconstructed === true)
318
+ }
319
+
320
+ /** req:new 게이트가 **차단**하는 기본 상태(DEC-C). 오버레이는 무관 — 기본 상태만 본다. */
321
+ export function baseStateBlocksIntake(state: CloseBaseState): boolean {
322
+ return state === 'developing' || state === 'needs-recovery'
323
+ }
@@ -64,6 +64,8 @@ export interface RawConfig {
64
64
  reviewBudget?: ReviewBudget
65
65
  /** REQ-2026-037: phase 자동 커밋 정책. 미지정 = DEFAULTS(never = 현행 매 phase 정지). */
66
66
  phaseCommit?: PhaseCommit
67
+ /** REQ-2026-056: true면 리뷰 프롬프트에 lockfile diff 전문을 담는다. 미지정/false = 요약(기본). */
68
+ lockfilePromptFull?: boolean
67
69
  }
68
70
 
69
71
  /** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
@@ -81,6 +83,7 @@ export interface ResolvedConfig {
81
83
  reviewReasoningEffort: ReviewReasoningEffort | null
82
84
  reviewBudget: ReviewBudget
83
85
  phaseCommit: PhaseCommit
86
+ lockfilePromptFull: boolean
84
87
  // 파생(절대경로)
85
88
  workflowDirAbs: string
86
89
  schemaPathAbs: string
@@ -132,6 +135,8 @@ export const DEFAULTS = {
132
135
  reviewBudget: { autoBudget: 5, hardCap: 8 } as ReviewBudget,
133
136
  // REQ-2026-037: phase 자동 커밋은 opt-in. 코어 기본 never = 현행(매 phase 정지) — 업그레이드로 완화되지 않는다.
134
137
  phaseCommit: { autoApprove: 'never' } as PhaseCommit,
138
+ // REQ-2026-056: lockfile 프롬프트 기본 요약(false). 전문이 필요하면 config에 true 명시(opt-in).
139
+ lockfilePromptFull: false,
135
140
  }
136
141
 
137
142
  const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
@@ -172,6 +177,8 @@ export const CONFIG_SCHEMA = {
172
177
  autoApprove: { type: 'string', enum: ['never', 'low-only'] },
173
178
  },
174
179
  },
180
+ // REQ-2026-056: lockfile 프롬프트 전문 opt-in.
181
+ lockfilePromptFull: { type: 'boolean' },
175
182
  designDocs: {
176
183
  type: 'object',
177
184
  additionalProperties: false,
@@ -259,6 +266,8 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
259
266
  reviewBudget: raw.reviewBudget ?? DEFAULTS.reviewBudget,
260
267
  // REQ-2026-037: 미지정 → DEFAULTS(never). `?? `로 충분(phaseCommit은 nullable 아님 — null 탈출구 없음).
261
268
  phaseCommit: raw.phaseCommit ?? DEFAULTS.phaseCommit,
269
+ // REQ-2026-056: 미지정 → DEFAULTS(false = 요약).
270
+ lockfilePromptFull: raw.lockfilePromptFull ?? DEFAULTS.lockfilePromptFull,
262
271
  }
263
272
 
264
273
  // REQ-2026-028 R7: 교차검증(스키마가 표현 못 함). AJV가 이미 hardCap∈[1,8]·autoBudget≥1을 잡았고,
@@ -48,7 +48,13 @@ export function createEvidencePorts(root: string, responsesDirRel: string): Evid
48
48
  },
49
49
  headText(repoRel) {
50
50
  try {
51
- return gitText(['show', `HEAD:${repoRel}`])
51
+ // 🔴 부재가 정상이므로 git stderr를 버린다(REQ-2026-058 F-5) — 판정은 그대로 `catch → null`.
52
+ return execFileSync('git', ['show', `HEAD:${repoRel}`], {
53
+ cwd: root,
54
+ encoding: 'utf8',
55
+ maxBuffer: 64 * 1024 * 1024,
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ })
52
58
  } catch {
53
59
  return null // HEAD에 없는 경로
54
60
  }
@@ -68,6 +74,7 @@ export function createEvidencePorts(root: string, responsesDirRel: string): Evid
68
74
  const buf = execFileSync('git', ['cat-file', 'blob', `HEAD:${repoRel}`], {
69
75
  cwd: root,
70
76
  maxBuffer: 64 * 1024 * 1024,
77
+ stdio: ['ignore', 'pipe', 'ignore'], // 부재가 정상 — stderr 노이즈 억제(REQ-2026-058 F-5)
71
78
  })
72
79
  return createHash('sha256').update(buf).digest('hex')
73
80
  } catch {
@@ -91,6 +91,16 @@ export interface ManifestEntry {
91
91
  review_base_sha: string
92
92
  approved_tree?: string
93
93
  design_hash?: string
94
+ /**
95
+ * 🔴 REQ-2026-052 DEC-B5(phase-3a2): **phase 전용** — 이 phase가 승인 시점에 결속된 committed design 참조
96
+ * (= `captureDesignBinding().designHash`, design 행 `design_hash`와 동일 계산·값). dev-complete 완전성이
97
+ * 이 값 == 현재 committed design_ref인 phase 행만 산입한다(design-blind면 D1 검토분이 D2 완료에 샌다).
98
+ *
99
+ * **선택 필드**다: 부재(레거시·이 보정 이전 커밋분)해도 매니페스트 검증은 통과한다(무회귀). 단 durable 완료
100
+ * 판정에서는 부재를 **불산입(fail-closed)** — 검증의 관대함과 완료의 엄격함을 분리(`archive_inventory`와 동형).
101
+ * design 행에는 **금지**(kind 격리). 미지정 시 키 자체를 넣지 않아 기존 phase 행과 바이트 동일.
102
+ */
103
+ phase_design_ref?: string
94
104
  approved_at: string
95
105
  consumed_at: string
96
106
  consumed_by_commit_sha: string
@@ -118,6 +128,7 @@ const MANIFEST_KEYS = new Set([
118
128
  'review_base_sha',
119
129
  'approved_tree',
120
130
  'design_hash',
131
+ 'phase_design_ref', // REQ-2026-052 DEC-B5(선택 — phase 전용·부재해도 유효)
121
132
  'approved_at',
122
133
  'consumed_at',
123
134
  'consumed_by_commit_sha',
@@ -165,12 +176,20 @@ export function designEvidenceStagePaths(
165
176
  inventory: readonly ArchiveInventoryItem[],
166
177
  responsePath: string,
167
178
  ticketRel: string,
179
+ /**
180
+ * 리뷰 원장이 디스크에 **존재하는가**(REQ-2026-051 D7). 존재할 때만 pathspec에 합류시킨다 —
181
+ * 없는 경로를 넣으면 `commitPaths`가 실패해 승인 증거 커밋 **전체**가 무산된다(원장 때문에 승인
182
+ * 증거를 잃는 것은 본말전도다).
183
+ * 생략 가능: 기존 3-arg 호출부를 깨지 않는다.
184
+ */
185
+ ledgerExists = false,
168
186
  ): string[] {
169
187
  const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
170
188
  const archives = [...inventory.map((i) => i.response_path), responsePath].filter(
171
189
  (p) => typeof p === 'string' && p.length > 0 && isConfinedArchivePath(p, ticketRel),
172
190
  )
173
- return [...new Set([...archives, `${dir}/approvals.jsonl`])]
191
+ const tail = ledgerExists ? [`${dir}/approvals.jsonl`, `${dir}/review-ledger.jsonl`] : [`${dir}/approvals.jsonl`]
192
+ return [...new Set([...archives, ...tail])]
174
193
  }
175
194
 
176
195
  /** 인벤토리 항목의 형식 문제 목록(순수). 빈 배열 = 정상. `line N: ` 접두는 호출부가 붙인다. */
@@ -248,7 +267,11 @@ export function buildManifestEntry(
248
267
  const approvedTree = ev.approved_tree
249
268
  if (typeof approvedTree !== 'string' || !approvedTree)
250
269
  throw new Error('buildManifestEntry: phase evidence에 approved_tree 누락(fail-fast)')
251
- return { ...base, approved_tree: approvedTree, ...inv }
270
+ // REQ-2026-052 DEC-B5: phase_design_ref(승인 시점 design 결속)가 있으면 phase 행에 포함. 미지정이면
271
+ // 키 자체를 넣지 않는다 — 레거시 phase 행과 바이트 동일(무회귀). durable 티켓은 designValid 게이트가
272
+ // 승인 전제라 정상 경로에서 항상 채워지고, 완료 판정이 이 값으로 design-bound 필터한다.
273
+ const pdr = typeof ev.phase_design_ref === 'string' && ev.phase_design_ref ? { phase_design_ref: ev.phase_design_ref } : {}
274
+ return { ...base, approved_tree: approvedTree, ...pdr, ...inv }
252
275
  }
253
276
 
254
277
  /** 매니페스트 한 줄 직렬화(JSONL): JSON + 끝 개행. 고정 키 순서라 deterministic. */
@@ -305,10 +328,15 @@ export function validateManifest(content: string, opts: { ticketRel: string; val
305
328
  problems.push(`line ${ln}: phase_id 비유효: ${String(e.phase_id)}`)
306
329
  if (typeof e.approved_tree !== 'string' || !GIT_OID_RE.test(e.approved_tree)) problems.push(`line ${ln}: approved_tree 비-OID`)
307
330
  if ('design_hash' in e) problems.push(`line ${ln}: phase entry에 design_hash 금지`)
331
+ // REQ-2026-052 DEC-B5: phase_design_ref는 **선택**(부재=레거시 무회귀). 있으면 64hex(design_hash와 동형).
332
+ if ('phase_design_ref' in e && (typeof e.phase_design_ref !== 'string' || !SHA256_RE.test(e.phase_design_ref)))
333
+ problems.push(`line ${ln}: phase_design_ref 비-64hex`)
308
334
  } else if (kind === 'design') {
309
335
  if (e.phase_id !== null) problems.push(`line ${ln}: design entry는 phase_id=null이어야`)
310
336
  if (typeof e.design_hash !== 'string' || !SHA256_RE.test(e.design_hash)) problems.push(`line ${ln}: design_hash 비-64hex`)
311
337
  if ('approved_tree' in e) problems.push(`line ${ln}: design entry에 approved_tree 금지`)
338
+ // REQ-2026-052 DEC-B5: kind 격리 — design 행에는 phase_design_ref 금지.
339
+ if ('phase_design_ref' in e) problems.push(`line ${ln}: design entry에 phase_design_ref 금지`)
312
340
  }
313
341
  // archive_inventory(REQ-2026-048 DEC-2): **선택** — 부재는 정상(기존 행 무회귀). 있으면 형식 검증.
314
342
  if ('archive_inventory' in e) {
@@ -371,6 +399,89 @@ export function findEvidenceRow(
371
399
  return null
372
400
  }
373
401
 
402
+ // ─────────────────── 매니페스트 순수 파서 (REQ-2026-052 phase-3b: req-commit에서 이동) ──
403
+ // req-commit(command)과 intake 스캔(leaf)이 같은 파서를 공유하도록 매니페스트 모델의 정본인 여기로 옮겼다.
404
+ // req-commit이 기존 경로로 re-export한다(호출부 호환).
405
+
406
+ /** 매니페스트(JSONL) 본문에서 안전하게 엔트리 배열을 뽑는다(파싱 불가 행은 건너뛴다 — 검증은 별도). */
407
+ export function parseManifestEntries(content: string): Array<Record<string, unknown>> {
408
+ const out: Array<Record<string, unknown>> = []
409
+ for (const line of content.split('\n')) {
410
+ if (line.trim() === '') continue
411
+ try {
412
+ const o = JSON.parse(line)
413
+ if (o && typeof o === 'object' && !Array.isArray(o)) out.push(o as Record<string, unknown>)
414
+ } catch {
415
+ /* skip */
416
+ }
417
+ }
418
+ return out
419
+ }
420
+
421
+ /**
422
+ * 매니페스트에서 커밋된 **phase 증거**가 있는 phase id 집합(consumed phase 엔트리).
423
+ *
424
+ * 🔴 REQ-2026-052 DEC-B5(phase-3a2): `designRef`를 주면 **design-bound** — 그 phase 행의 `phase_design_ref`가
425
+ * `designRef`와 **일치**하는 것만 산입한다. dev-complete 완전성이 이 필터를 써야 D1에서 검토된 phase가 D2
426
+ * 재승인 후 D2 완료 증명에 새어들지 않는다. `phase_design_ref` 부재 행(레거시·보정 이전)은 **불산입**(fail-closed).
427
+ * `designRef` 미지정(하위호환)이면 결속 무관 전량(옛 동작) — 신규 완료 경로는 항상 designRef를 준다.
428
+ */
429
+ export function evidencedPhaseIdsFromManifest(content: string, designRef?: string | null): string[] {
430
+ return parseManifestEntries(content)
431
+ .filter((e) => e.kind === 'phase' && typeof e.phase_id === 'string')
432
+ .filter((e) => (designRef == null ? true : e.phase_design_ref === designRef))
433
+ .map((e) => e.phase_id as string)
434
+ }
435
+
436
+ /** 매니페스트에서 커밋된 design 승인의 design_hash(가장 마지막 design 엔트리). 없으면 null. */
437
+ export function designHashFromManifest(content: string): string | null {
438
+ const designs = parseManifestEntries(content).filter((e) => e.kind === 'design' && typeof e.design_hash === 'string')
439
+ return designs.length ? (designs[designs.length - 1]!.design_hash as string) : null
440
+ }
441
+
442
+ // ───────── phase 승인 archive 무결성 (REQ-2026-052 DEC-B6·phase-3b2) — 공유 leaf 모듈 ──
443
+
444
+ export interface PhaseArchiveProblem {
445
+ phaseId: string
446
+ responsePath: string
447
+ reason: 'missing' | 'sha-mismatch'
448
+ }
449
+
450
+ /**
451
+ * 🔴 phase 승인 **archive blob 무결성** 검증(순수 + `headBlobSha256` 포트, DEC-B6). intake(`scanTicketIntake`)와
452
+ * req:commit 발행 후 verifier(`verifyDevCompleteAtHead`)가 **이 한 모듈을 공유**한다 → 두 경로의 phase archive
453
+ * 규칙이 갈라질 수 없다(요구 #3).
454
+ *
455
+ * 각 phase manifest 행에 대해: `response_path` blob이 **HEAD에 존재**하고(`headBlobSha256` non-null) 그
456
+ * sha256이 `response_sha256`과 **일치**하는지 확인한다. archive를 삭제하면 `missing`, 바이트를 바꾸면
457
+ * `sha-mismatch` — 둘 다 archive 손상(corrupt)이다. manifest 행 형식(`response_path`/`response_sha256`)은 여기서
458
+ * 재검증하지 않는다(그건 `validateManifest` 몫) — 형식이 이미 통과한 행의 **blob 정합**만 본다.
459
+ *
460
+ * 🔴 **강한 정책(요구 #1 우선안)**: `onlyDesignRef`를 주지 않으면 **모든** phase 행을 검증한다(inventory 한정 아님).
461
+ * 감사 내구성상 재승인 이전 라운드 행의 archive까지 온전해야 한다. `onlyDesignRef` 지정 시 그 design_ref에
462
+ * 결속된 phase 행만(부분 검증 — 필요 시).
463
+ *
464
+ * 🔴 on-disk·워킹트리를 절대 읽지 않는다 — `headBlobSha256`(HEAD blob 바이트 sha)만. leaf라 git을 직접 모른다.
465
+ */
466
+ export function verifyPhaseArchives(
467
+ content: string,
468
+ headBlobSha256: (repoRel: string) => string | null,
469
+ onlyDesignRef?: string | null,
470
+ ): PhaseArchiveProblem[] {
471
+ const problems: PhaseArchiveProblem[] = []
472
+ for (const e of parseManifestEntries(content)) {
473
+ if (e.kind !== 'phase' || typeof e.phase_id !== 'string' || e.phase_id === '') continue
474
+ if (onlyDesignRef != null && e.phase_design_ref !== onlyDesignRef) continue
475
+ const responsePath = typeof e.response_path === 'string' ? e.response_path : ''
476
+ const expectedSha = typeof e.response_sha256 === 'string' ? e.response_sha256 : ''
477
+ if (!responsePath || !expectedSha) continue // 형식 문제는 validateManifest가 corrupt로 잡는다(중복 판정 안 함).
478
+ const actual = headBlobSha256(responsePath)
479
+ if (actual === null) problems.push({ phaseId: e.phase_id, responsePath, reason: 'missing' })
480
+ else if (actual !== expectedSha) problems.push({ phaseId: e.phase_id, responsePath, reason: 'sha-mismatch' })
481
+ }
482
+ return problems
483
+ }
484
+
374
485
  // ─────────────────────────── design evidence 내구화 (REQ-2026-048 DEC-3) ──
375
486
 
376
487
  /**
@@ -483,7 +594,11 @@ export function durableDesignEvidence(args: {
483
594
  ports.writeText(manifestRel, candidate)
484
595
  }
485
596
 
486
- const stagePaths = designEvidenceStagePaths(inventory, ev.response_path, ticketRel)
597
+ // REQ-2026-051 D7: 원장이 있으면 같은 커밋에 싣는다. `readText`로 존재를 확인한다 — 포트 경계를
598
+ // 넘지 않고(fs 직접 접근 금지), 없으면 pathspec에 넣지 않아 커밋이 실패하지 않는다.
599
+ const ledgerRel = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/review-ledger.jsonl`
600
+ const ledgerExists = ports.readText(ledgerRel) !== null
601
+ const stagePaths = designEvidenceStagePaths(inventory, ev.response_path, ticketRel, ledgerExists)
487
602
  // 🔴 가드는 **우리가 커밋할 경로**에만 건다 — index 전체가 아니다(phase-3 리뷰 P1).
488
603
  // index 전체를 보면 설계 문서를 stage한 정상 승인 경로에서 항상 실패한다. pathspec 범위 커밋이라
489
604
  // 무관한 staged 변경은 애초에 이 커밋에 들어갈 수 없고, 커밋 후에도 index에 그대로 남는다.
@@ -628,6 +743,46 @@ export function verifyCommittedDesignEvidence(args: {
628
743
  return { durable: true, reason: 'design 승인 증거가 HEAD에 완비됨' }
629
744
  }
630
745
 
746
+ // ───────── committed 증거 무결성 종합(design+phase) (REQ-2026-052 DEC-B7·phase-3b3) — 공유 deep 모듈 ──
747
+
748
+ /**
749
+ * 🔴 durable 티켓의 **committed 증거 무결성 종합 판정**(design + phase, DEC-B7). intake(`scanTicketIntake`)와
750
+ * req:commit 발행 후 verifier(`verifyDevCompleteAtHead`)가 **이 한 함수를 공유**한다(요구 #3) — 두 경로의
751
+ * 증거 무결성 규칙이 갈라질 수 없다. 호출자는 design·phase 검증 순서·세부 조건을 몰라도 된다(얕은 인터페이스 금지).
752
+ *
753
+ * 내부는 **기존 함수 재사용**(중복 규칙 없음):
754
+ * - **design**: manifest에 design 행이 있으면 **`verifyCommittedDesignEvidence`**(REQ-2026-048 DONE 게이트)로
755
+ * 최신 design 승인 archive 존재·SHA + `archive_inventory` 전체(과거 needs-fix 포함) 존재·SHA + HEAD 집합 정합을
756
+ * 검증. `durable=false`면 무결성/완전성 실패.
757
+ * - **phase**: **`verifyPhaseArchives`**(DEC-B6)로 모든 phase 행 archive 존재·SHA.
758
+ *
759
+ * 🔴 **불완전(incomplete) ≠ 손상(tampered)**: manifest에 **design 행이 없으면** design 무결성 검사 대상이 아니다
760
+ * (대체된 미완 티켓·design 미승인은 손상이 아니라 미완 — 통과 가능, series-terminal). `designEvidenceComplete`은
761
+ * needs-recovery 판정 입력(design 행 없으면 false = 미완).
762
+ *
763
+ * 🔴 **HEAD blob만** — on-disk·워킹트리·state.json을 판정 근거로 쓰지 않는다(포트 경유).
764
+ */
765
+ export function verifyCommittedEvidenceIntegrity(args: {
766
+ ticketRel: string
767
+ manifestText: string | null
768
+ ports: Pick<EvidencePorts, 'headText' | 'headBlobSha256' | 'headArchivePaths'>
769
+ }): { problems: string[]; designEvidenceComplete: boolean } {
770
+ const { ticketRel, manifestText, ports } = args
771
+ const problems: string[] = []
772
+ let designEvidenceComplete = false
773
+ if (manifestText === null) return { problems, designEvidenceComplete }
774
+ // design: design 행이 있을 때만 검사(없으면 손상 아님 — 불완전≠손상).
775
+ if (designHashFromManifest(manifestText) !== null) {
776
+ const d = verifyCommittedDesignEvidence({ ticketRel, ports })
777
+ designEvidenceComplete = d.durable
778
+ if (!d.durable) problems.push(`design 증거 무결성/완전성 실패: ${d.reason}`)
779
+ }
780
+ // phase: 모든 phase 행 archive 무결성.
781
+ for (const p of verifyPhaseArchives(manifestText, ports.headBlobSha256))
782
+ problems.push(`phase archive ${p.reason}: ${p.phaseId} (${p.responsePath})`)
783
+ return { problems, designEvidenceComplete }
784
+ }
785
+
631
786
  /**
632
787
  * approvals.jsonl에 **이 evidence가** 이미 finalize된 엔트리가 있는지(순수, 멱등 finalize용).
633
788
  * ⚠️ B3-R2: consumed_by_commit_sha만으로는 부족 — 같은 source SHA를 쓰는 design-finalize row 등에 오인될 수 있음.