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/package.json CHANGED
@@ -1,76 +1,76 @@
1
- {
2
- "name": "commitgate",
3
- "version": "0.9.6",
4
- "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "sol5288",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/sol5288/commitgate.git"
11
- },
12
- "homepage": "https://github.com/sol5288/commitgate#readme",
13
- "bugs": {
14
- "url": "https://github.com/sol5288/commitgate/issues"
15
- },
16
- "keywords": [
17
- "ai",
18
- "code-review",
19
- "workflow",
20
- "codex",
21
- "claude",
22
- "git",
23
- "commit-gate",
24
- "fail-closed",
25
- "review-gate",
26
- "cli"
27
- ],
28
- "bin": {
29
- "commitgate": "bin/commitgate.mjs"
30
- },
31
- "scripts": {
32
- "req:new": "tsx scripts/req/req-new.ts",
33
- "req:review-codex": "tsx scripts/req/review-codex.ts",
34
- "req:doctor": "tsx scripts/req/req-doctor.ts",
35
- "req:next": "tsx scripts/req/req-next.ts",
36
- "req:commit": "tsx scripts/req/req-commit.ts",
37
- "test": "vitest run",
38
- "typecheck": "tsc --noEmit",
39
- "smoke": "node scripts/smoke.mjs",
40
- "verify:overrides": "node scripts/verify-review-overrides.mjs",
41
- "docs:lint": "remark docs README.md README.en.md --quiet --frail"
42
- },
43
- "files": [
44
- "scripts/req",
45
- "scripts/verify-review-overrides.mjs",
46
- "workflow/machine.schema.json",
47
- "workflow/req.config.schema.json",
48
- "workflow/review-persona.md",
49
- "bin",
50
- "templates",
51
- "skills",
52
- "AGENTS.template.md",
53
- "req.config.json.sample",
54
- "README.md",
55
- "README.en.md",
56
- "CHANGELOG.md"
57
- ],
58
- "engines": {
59
- "node": ">=18.17"
60
- },
61
- "dependencies": {
62
- "ajv": "^8.20.0",
63
- "cross-spawn": "^7.0.6",
64
- "semver": "^7.6.3",
65
- "tsx": "^4.19.1"
66
- },
67
- "devDependencies": {
68
- "@types/cross-spawn": "^6.0.6",
69
- "@types/node": "^22.7.4",
70
- "@types/semver": "^7.5.8",
71
- "remark-cli": "^12.0.1",
72
- "remark-validate-links": "^13.1.0",
73
- "typescript": "^5.6.2",
74
- "vitest": "^2.1.2"
75
- }
76
- }
1
+ {
2
+ "name": "commitgate",
3
+ "version": "0.9.9",
4
+ "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "sol5288",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sol5288/commitgate.git"
11
+ },
12
+ "homepage": "https://github.com/sol5288/commitgate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/sol5288/commitgate/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "code-review",
19
+ "workflow",
20
+ "codex",
21
+ "claude",
22
+ "git",
23
+ "commit-gate",
24
+ "fail-closed",
25
+ "review-gate",
26
+ "cli"
27
+ ],
28
+ "bin": {
29
+ "commitgate": "bin/commitgate.mjs"
30
+ },
31
+ "scripts": {
32
+ "req:new": "tsx scripts/req/req-new.ts",
33
+ "req:review-codex": "tsx scripts/req/review-codex.ts",
34
+ "req:doctor": "tsx scripts/req/req-doctor.ts",
35
+ "req:next": "tsx scripts/req/req-next.ts",
36
+ "req:commit": "tsx scripts/req/req-commit.ts",
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit",
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "verify:overrides": "node scripts/verify-review-overrides.mjs",
41
+ "docs:lint": "remark docs README.md README.en.md --quiet --frail"
42
+ },
43
+ "files": [
44
+ "scripts/req",
45
+ "scripts/verify-review-overrides.mjs",
46
+ "workflow/machine.schema.json",
47
+ "workflow/req.config.schema.json",
48
+ "workflow/review-persona.md",
49
+ "bin",
50
+ "templates",
51
+ "skills",
52
+ "AGENTS.template.md",
53
+ "req.config.json.sample",
54
+ "README.md",
55
+ "README.en.md",
56
+ "CHANGELOG.md"
57
+ ],
58
+ "engines": {
59
+ "node": ">=18.17"
60
+ },
61
+ "dependencies": {
62
+ "ajv": "^8.20.0",
63
+ "cross-spawn": "^7.0.6",
64
+ "semver": "^7.6.3",
65
+ "tsx": "^4.19.1"
66
+ },
67
+ "devDependencies": {
68
+ "@types/cross-spawn": "^6.0.6",
69
+ "@types/node": "^22.7.4",
70
+ "@types/semver": "^7.5.8",
71
+ "remark-cli": "^12.0.1",
72
+ "remark-validate-links": "^13.1.0",
73
+ "typescript": "^5.6.2",
74
+ "vitest": "^2.1.2"
75
+ }
76
+ }
@@ -46,6 +46,38 @@ export function safeSpawnSync(file: string, args: readonly string[], opts: SafeS
46
46
  return res.stdout ? res.stdout.toString('utf8') : ''
47
47
  }
48
48
 
49
+ /**
50
+ * `safeSpawnSync`의 **exit code 보존** 변형 (REQ-2026-050 D5).
51
+ *
52
+ * `safeSpawnSync`는 non-zero를 전부 실패로 보고 throw한다. 그러나 **non-zero가 정상 신호**인 명령이 있다 —
53
+ * `git diff --no-index`는 두 파일 내용이 다르면 exit **1**을 낸다(정상 결과이지 오류가 아니다).
54
+ * 그런 명령에 `safeSpawnSync`를 쓰면 정상 결과를 오류로 오판한다.
55
+ *
56
+ * 🔴 **새 spawn 경로를 만드는 것이 아니다.** shell 없는 `cross-spawn` 단일 경로(주입 차단 경계)를 그대로
57
+ * 재사용하고, 달라지는 것은 **exit code 해석을 호출자에게 넘긴다**는 것뿐이다. 어떤 code가 정상인지는
58
+ * 명령마다 다르므로 여기서 정책을 갖지 않는다.
59
+ *
60
+ * spawn 자체의 실패(`res.error` — 예: git 부재 ENOENT)는 여기서도 throw한다. 그건 code 해석의 문제가 아니다.
61
+ */
62
+ export function safeSpawnSyncStatus(
63
+ file: string,
64
+ args: readonly string[],
65
+ opts: SafeSpawnOptions = {},
66
+ ): { status: number | null; stdout: string; stderr: string } {
67
+ const res = spawn.sync(file, args as string[], {
68
+ cwd: opts.cwd,
69
+ input: opts.input,
70
+ stdio: opts.stdio ?? 'pipe',
71
+ maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
72
+ })
73
+ if (res.error) throw res.error
74
+ return {
75
+ status: res.status,
76
+ stdout: res.stdout ? res.stdout.toString('utf8') : '',
77
+ stderr: res.stderr ? res.stderr.toString('utf8') : '',
78
+ }
79
+ }
80
+
49
81
  // ──────────────────────────────────────────────────────────── Git ──
50
82
 
51
83
  /** git 호출 경계(D-017-3). exec(args) = trim된 stdout, 실패 시 throw(fail-closed). */
@@ -106,12 +138,48 @@ export function parseThreadId(jsonl: string): string | null {
106
138
  return null
107
139
  }
108
140
 
109
- /** codex 실행자(주입 가능 — 테스트). stdout 반환, 실패 시 throw. */
141
+ /**
142
+ * 리뷰 호출 실패의 **타입된 분류**(REQ-2026-054·DEC-C1). 예산 환불·lifecycle 판정이 메시지 문자열 sniffing이
143
+ * 아니라 이 타입으로 결정된다.
144
+ * - `pre-dispatch`: reviewer subprocess가 **기동조차 못 함**(spawn 실패·ENOENT). 청구 불가 → 환불 대상.
145
+ * - `dispatched`: subprocess는 떴으나 사용 가능한 결과 없음(non-zero exit·thread_id 없음). 청구 가능성 → 차감.
146
+ */
147
+ export class ReviewCallError extends Error {
148
+ readonly dispatchPhase: 'pre-dispatch' | 'dispatched'
149
+ constructor(dispatchPhase: 'pre-dispatch' | 'dispatched', message: string) {
150
+ super(message)
151
+ this.name = 'ReviewCallError'
152
+ this.dispatchPhase = dispatchPhase
153
+ }
154
+ }
155
+
156
+ /** codex 실행자(주입 가능 — 테스트). stdout 반환, 실패 시 throw(`ReviewCallError`로 dispatch 단계 분류). */
110
157
  export type CodexRunner = (args: string[], input: string, cwd: string) => string
111
- const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
112
- // shell 없이 안전 실행(safeSpawnSync/cross-spawn). 프롬프트는 stdin(input)으로 전달.
113
- // ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId ) 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 P1 수정.
114
- safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
158
+
159
+ /** status 보존 spawn 함수 타입(주입 seam — `safeSpawnSyncStatus` 서명). */
160
+ export type StatusSpawn = (file: string, args: readonly string[], opts?: SafeSpawnOptions) => { status: number | null; stdout: string; stderr: string }
161
+
162
+ /**
163
+ * codex 러너 팩토리(REQ-2026-054·DEC-C1). spawn을 주입해 분류 로직을 테스트 가능하게 한다.
164
+ * - spawn 자체 실패(`res.error` throw — ENOENT 등): subprocess 미기동 = `pre-dispatch`(청구 불가·환불 대상).
165
+ * - `res.status !== 0`(subprocess는 떴고 non-zero — usage limit 등 모델 부분 실행 가능): `dispatched`(차감).
166
+ */
167
+ export function makeCodexRunner(spawn: StatusSpawn): CodexRunner {
168
+ return (args, input, cwd) => {
169
+ // shell 없이 안전 실행(cross-spawn). 프롬프트는 stdin(input). 과거 `shell:true`의 명령 주입/공백 경로 결함 방지.
170
+ let res: { status: number | null; stdout: string; stderr: string }
171
+ try {
172
+ res = spawn('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
173
+ } catch (err) {
174
+ throw new ReviewCallError('pre-dispatch', `codex 실행(spawn) 실패 — subprocess 미기동: ${err instanceof Error ? err.message : String(err)}`)
175
+ }
176
+ if (res.status !== 0)
177
+ throw new ReviewCallError('dispatched', `codex 종료 코드 ${res.status ?? 'null'}: ${res.stderr.trim()}`.trim())
178
+ return res.stdout
179
+ }
180
+ }
181
+
182
+ const defaultCodexRunner: CodexRunner = makeCodexRunner(safeSpawnSyncStatus)
115
183
 
116
184
  /** unknown → 평범한 객체(배열·null 제외). 스키마 경로 탐색용. */
117
185
  function asPlainObject(v: unknown): Record<string, unknown> | null {
@@ -202,13 +270,37 @@ export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner
202
270
  }
203
271
 
204
272
  /** 테스트 전용 ReviewerAdapter — canned 응답 반환 + 받은 요청 기록(live codex 없이 review-codex 플로 검증, 수용기준 #4). */
205
- export function createFakeReviewerAdapter(result: ReviewResult): ReviewerAdapter & { requests: ReviewRequest[] } {
273
+ export function createFakeReviewerAdapter(
274
+ result: ReviewResult,
275
+ opts: {
276
+ /**
277
+ * REQ-2026-052: `true`(기본)면 응답의 `review_base_sha`를 **프롬프트의 `REVIEW_BASE_SHA`로 덮어쓴다**.
278
+ * pre-call 원장 커밋이 HEAD를 옮겨 실제 approval base가 테스트 setup 시점의 head와 달라지므로, 고정
279
+ * canned 응답의 base가 stale해진다. 프롬프트 base를 echo하면 near-e2e가 그 커밋을 신경 쓰지 않아도 된다.
280
+ * base 불일치(invalid)를 **의도적으로** 테스트하려면 `echoPromptBase:false`로 끈다.
281
+ */
282
+ echoPromptBase?: boolean
283
+ } = {},
284
+ ): ReviewerAdapter & { requests: ReviewRequest[] } {
206
285
  const requests: ReviewRequest[] = []
286
+ const echo = opts.echoPromptBase !== false
207
287
  return {
208
288
  requests,
209
289
  review(req: ReviewRequest): ReviewResult {
210
290
  requests.push(req)
211
- return result
291
+ if (!echo) return result
292
+ // 프롬프트에서 `REVIEW_BASE_SHA: <sha>` 추출.
293
+ const m = /^REVIEW_BASE_SHA:\s*([0-9a-f]{7,64})\s*$/m.exec(req.prompt)
294
+ if (!m) return result
295
+ let parsed: unknown
296
+ try {
297
+ parsed = JSON.parse(result.lastMessage)
298
+ } catch {
299
+ return result // JSON이 아니면 손대지 않는다.
300
+ }
301
+ if (!parsed || typeof parsed !== 'object' || !('review_base_sha' in parsed)) return result
302
+ const patched = { ...(parsed as Record<string, unknown>), review_base_sha: m[1] }
303
+ return { ...result, lastMessage: JSON.stringify(patched) }
212
304
  },
213
305
  }
214
306
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `req:close --migrate`의 **순수 판정기** (REQ-2026-053·DEC-M3/M7).
3
+ *
4
+ * close-proof/`phase_design_ref` regime **이전에** 완료·병합돼 dev-complete로 자기증명될 수 없는 레거시
5
+ * durable 티켓을, HEAD-committed 증거만으로 마이그레이션 종결 자격을 판정한다. IO는 호출부(`req-close.ts`)가 낸다 —
6
+ * 이 모듈은 fs·git을 모른다(`lib/close-proof`·`lib/reconstruct`와 같은 태도).
7
+ *
8
+ * 🔴 자격은 좁다: **손상 아님 + durable + 커밋된 design 승인 + phase 증거 ≥1 + 정상 dev-complete 불가 +
9
+ * integrated(본선 병합)**. 하나라도 어긋나면 fail-closed 거부. 이미 종결이면 거부가 아니라 성공 no-op(멱등).
10
+ *
11
+ * 🔴 **`migrated-complete` close 이벤트·기본 상태·파서·`deriveBaseState` 확장(dev-complete 아래·needs-recovery
12
+ * 위 비차단)은 phase-1(커밋 `3ed1b95` `close-proof.ts`)에 이미 landed.** 이 모듈(phase-2)은 그 이벤트를
13
+ * **발행할지 판정**만 한다. 종결→intake pass 전 파이프라인은 `req-close.test.ts` ⑮가 실 git으로 실증한다.
14
+ */
15
+ import type { CloseProofRow, CloseProofEvent } from './close-proof'
16
+
17
+ /** 판정 입력 — 전부 HEAD-committed 사실 + integrated(git ancestry, 호출부가 계산). */
18
+ export interface MigrationFacts {
19
+ ticketId: string
20
+ ticketRel: string
21
+ /** HEAD scaffold marker(`isDurabilityRequired`). false면 legacy. */
22
+ durabilityRequired: boolean
23
+ /** HEAD approvals.jsonl 본문(없으면 null). */
24
+ manifestText: string | null
25
+ /** `validateManifest` 결과(빈 배열=정상). 비어있지 않으면 corrupt 거부. */
26
+ manifestProblems: readonly string[]
27
+ /** HEAD ticket-close.jsonl 파싱 problems. 비어있지 않으면 corrupt 거부. */
28
+ closeProblems: readonly string[]
29
+ /** HEAD ticket-close.jsonl 파싱 행(이미 종결 여부 판정). */
30
+ closeRows: readonly CloseProofRow[]
31
+ /** committed 증거(design+phase) 무결성 problems(DEC-B6·B7). 비어있지 않으면 corrupt 거부. */
32
+ evidenceIntegrityProblems: readonly string[]
33
+ /** 커밋된 design 승인 참조(design_hash). 없으면 null. */
34
+ committedDesignRef: string | null
35
+ /** 매니페스트의 **모든** phase-evidenced id(결속 무관). 완료 inventory 원천. */
36
+ evidencedPhaseIdsAll: readonly string[]
37
+ /** **현재 design_ref에 결속된** phase id(정상 dev-complete 가능성 판정). */
38
+ evidencedPhaseIdsBound: readonly string[]
39
+ /**
40
+ * 🔴 티켓의 **커밋된 phase 계획**(HEAD state.json `phases[].id`). r02 P1 대응 — integrated(마지막 매니페스트
41
+ * 커밋이 본선 조상)만으로는 "앞 phase만 병합되고 뒷 phase가 진행 중/중단"인 부분 완료를 못 거른다.
42
+ * 커밋된 계획이 있으면 그 **모든** phase가 증거로 있어야 완료로 본다. 계획이 비었으면(레거시 스캐폴드
43
+ * state.phases=[]) 이 검사는 vacuous — dev-completable(결속) 검사와 integrated가 남은 방어다.
44
+ */
45
+ committedPlannedPhaseIds: readonly string[]
46
+ /** 티켓 증거가 본선(mainline)의 조상인가(완료성 증명 — DEC-M3.7). 호출부가 git ancestry로 계산. */
47
+ integrated: boolean
48
+ /** 발행 시각(ISO, 호출부가 실시계로 넣음). */
49
+ nowIso: string
50
+ /** 발행 행의 evidence_basis(마이그레이션 근거 아티팩트 경로 — 비어있으면 안 됨). */
51
+ evidenceBasis: readonly string[]
52
+ }
53
+
54
+ export type MigrationPlan =
55
+ | { kind: 'stamp'; row: CloseProofRow }
56
+ /** 이미 terminal close(dev-complete/series-terminal/migrated-complete) — 성공 no-op(DEC-M7). */
57
+ | { kind: 'noop'; existingState: CloseProofEvent }
58
+ /** 자격 미달 — fail-closed 거부(비-스탬프). */
59
+ | { kind: 'refuse'; reason: string; hint: string }
60
+
61
+ function refuse(reason: string, hint: string): MigrationPlan {
62
+ return { kind: 'refuse', reason, hint }
63
+ }
64
+
65
+ /**
66
+ * 마이그레이션 종결 계획(순수·DEC-M3/M7). 판정 순서가 계약이다:
67
+ * corrupt 가드 → durability → **이미 종결이면 no-op** → design 승인 → phase 증거 → 정상 dev-complete 불가 →
68
+ * integrated. 마지막에만 stamp.
69
+ */
70
+ export function planMigrationClose(f: MigrationFacts): MigrationPlan {
71
+ // 🔴 corrupt 가드 — pass 조건이 읽는 아티팩트가 손상됐으면 완료를 스탬프하지 않는다(fail-closed).
72
+ if (f.manifestText !== null && f.manifestProblems.length)
73
+ return refuse(`HEAD approvals.jsonl 손상: ${f.manifestProblems.slice(0, 3).join('; ')}`, '손상 증거에는 완료를 스탬프하지 않습니다 — 먼저 정정/복구')
74
+ if (f.closeProblems.length)
75
+ return refuse(`HEAD ticket-close.jsonl 손상: ${f.closeProblems.slice(0, 3).join('; ')}`, '손상 close-proof 정리 후 재시도')
76
+ if (f.evidenceIntegrityProblems.length)
77
+ return refuse(`committed 증거(design·phase archive) 손상/부재: ${f.evidenceIntegrityProblems.slice(0, 3).join('; ')}`, 'req:reconstruct 등으로 복구 후 재시도')
78
+
79
+ // durability marker 없음 = legacy → intake가 애초에 차단하지 않으므로 종결 불필요.
80
+ if (!f.durabilityRequired)
81
+ return refuse('legacy 티켓(durability marker 없음) — intake가 차단하지 않아 종결이 불필요', '조치 불필요')
82
+
83
+ // 🔴 DEC-M7: 이미 terminal close면 거부가 아니라 성공 no-op(재실행 멱등). 다른 검사보다 앞에 둬 재실행이
84
+ // 깨끗한 no-op이 되게 한다(기존 행의 at을 보존).
85
+ const terminal = f.closeRows.find(
86
+ (r) => r.event === 'series-terminal' || r.event === 'dev-complete' || r.event === 'migrated-complete',
87
+ )
88
+ if (terminal) return { kind: 'noop', existingState: terminal.event }
89
+
90
+ // 커밋된 design 승인 — 무엇에 대한 완료인지 불명이면 거부.
91
+ if (f.committedDesignRef === null)
92
+ return refuse('커밋된 design 승인(design_hash)이 없다 — 무엇에 대한 완료인지 불명', 'design 승인 증거 없이 마이그레이션 불가')
93
+
94
+ // phase 증거 ≥1 — 실제 phase를 거친 티켓만.
95
+ const inventory = [...new Set(f.evidencedPhaseIdsAll)].sort()
96
+ if (inventory.length === 0)
97
+ return refuse('커밋된 phase 증거가 없다 — 실제 phase를 거친 티켓만 마이그레이션', 'phase 리뷰·커밋을 먼저 완료')
98
+
99
+ // 🔴 r02 P1: **부분 완료(진행 중/중단) 배제** — 커밋된 phase 계획(state.phases)이 있으면 그 모든 phase가
100
+ // 증거로 있어야 한다. integrated는 "마지막 매니페스트 커밋이 본선 조상"만 봐서, 앞 phase만 병합되고 뒤
101
+ // phase가 아직 증거를 안 낸 티켓을 통과시킨다 — 이 committed 계획 검사가 그 틈을 닫는다.
102
+ const evidencedSet = new Set(inventory)
103
+ const missingPlanned = f.committedPlannedPhaseIds.filter((id) => !evidencedSet.has(id))
104
+ if (missingPlanned.length)
105
+ return refuse(
106
+ `커밋된 phase 계획 중 증거 없는 phase: ${missingPlanned.join(', ')} — 부분 완료(진행 중/중단) 티켓은 마이그레이션 불가`,
107
+ '모든 계획 phase를 완료·커밋(정상 dev-complete)하거나 종결한 뒤 재시도',
108
+ )
109
+
110
+ // 🔴 정상 dev-complete가 가능하면 거부 — 마이그레이션으로 강한 경로를 우회하지 않는다(DEC-M3.6).
111
+ const bound = new Set(f.evidencedPhaseIdsBound)
112
+ if (inventory.every((id) => bound.has(id)))
113
+ return refuse('phase 증거가 현재 design_ref에 전부 결속됨 — 정상 dev-complete 가능', '`req:commit --finalize --run`(정상 완료 경로)을 사용')
114
+
115
+ // 🔴 완료성 증명 = integrated(DEC-M3.7·P1-1) — 본선 미병합이면 진행 중일 수 있어 거부.
116
+ if (!f.integrated)
117
+ return refuse('티켓 작업이 본선(mainline)에 병합되지 않음 — 미완료/진행 중 가능성', '완료·병합 후 재시도(마이그레이션은 병합된 완료 티켓만)')
118
+
119
+ // 방어: evidence_basis 비어있으면 스키마 위반(reconstructed:true는 근거 필수). 호출부가 항상 채운다.
120
+ if (f.evidenceBasis.length === 0)
121
+ return refuse('evidence_basis가 비어 있음(내부 오류 — 마이그레이션 근거 경로 미제공)', '버그 신고')
122
+
123
+ const row: CloseProofRow = {
124
+ ticket_id: f.ticketId,
125
+ event: 'migrated-complete',
126
+ series_id: null,
127
+ resolution: null,
128
+ phase_inventory: inventory,
129
+ design_ref: f.committedDesignRef,
130
+ at: f.nowIso,
131
+ reconstructed: true,
132
+ evidence_basis: [...f.evidenceBasis],
133
+ }
134
+ return { kind: 'stamp', row }
135
+ }