commitgate 0.9.9 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  이 프로젝트는 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
4
4
 
5
+ ## 0.9.10
6
+
7
+ **티켓을 끝낸 뒤 다음 티켓을 시작할 수 있습니다** (REQ-2026-057~059, 실제 Nuxt 소비자 프로젝트에 설치→사용→제거 전 과정을 따라간 감사의 후속).
8
+
9
+ - **작업 상태가 승인 증거와 함께 커밋됩니다 — durable state checkpoint** (REQ-2026-057). 지금까지 `req:commit`의 evidence-finalize는 `responses/`만 커밋하고 **소비된 상태는 커밋 뒤에 디스크에만** 썼습니다. 그래서 티켓을 정상 완주해도 `state.json`이 dirty로 남았고, 그 파일이 **다음 `req:new`의 clean-tree 게이트를 막았습니다**(`req:new`의 스크래치 예외는 증거 변조를 막으려고 `state.json`을 의도적으로 제외합니다). 그런데 계약과 문서는 `state.json`을 직접 커밋하지 말라고 하므로 **남겨도 막히고 버려도 안 되는** 상태였고, 실제로 버리면(문서가 지시하는 유일한 해소책) 커밋된 승인 증거가 있는데도 `req:next`가 **설계 재리뷰를 지시**했습니다(유료 Codex 호출). `git checkout <다른 브랜치>`도 같은 이유로 막혔습니다.
10
+
11
+ 이제 **design 승인 직후**와 **phase 소비 직후**에 해당 티켓의 `state.json` **한 경로만** 담는 pathspec 커밋을 발행합니다. 🔴 **순서를 바꾸지 않았습니다** — 소비를 evidence 커밋 앞으로 옮겨 한 커밋에 담으면 `consumeState`가 `pending_evidence_for`·`approval_evidence`를 제거하므로 커밋 실패 시 `req:commit --finalize` 복구가 근거를 잃습니다. 🔴 evidence 커밋의 **"`responses/` 외 staged 금지" 가드도 완화하지 않았습니다** — 상태는 자기 커밋으로 갑니다. 커밋 전에 디스크 내용이 도구가 방금 쓴 상태와 **바이트 동일한지**, `state.id`가 대상 티켓과 일치하는지 확인하고 아니면 fail-closed합니다. 변경이 없으면 커밋하지 않습니다(멱등). checkpoint 실패는 승인·커밋 판정을 바꾸지 않고 경고만 냅니다.
12
+
13
+ `computeReviewSemanticIdentity`에서 **`state.json`을 제외**했습니다. checkpoint가 인덱스의 그 항목을 갱신하므로, 제외하지 않으면 **방금 승인한 리뷰를 `req:next` G2가 stale로 오판**합니다 — `responses/`를 제외한 것과 같은 이유입니다. 승인 바인딩(D9)은 그대로라 방어가 약해지지 않습니다.
14
+
15
+ - **안내가 그대로 실행 가능해지고, 정상 상태가 실패처럼 보이지 않습니다** (REQ-2026-058).
16
+ - `req:next`가 사람 승인(`AWAIT_HUMAN`) 경로에서 출력하던 커밋 명령에 **메시지 자리표시자가 빠져** 있어, 그대로 실행하면 `req:doctor` 17개 체크를 모두 통과한 **뒤에** `커밋 메시지 필요`로 죽었습니다(LOW 자동 커밋 경로에만 자리표시자가 있었습니다). 두 경로가 **같은 상수**를 공유하도록 했습니다.
17
+ - HEAD에 증거가 아직 없는 **정상 상태**에서 git의 `fatal: path … does not exist in 'HEAD'`가 그대로 표출됐습니다(코드는 이미 `catch → null`로 처리하고 있었습니다). 부재가 정상인 조회 4곳에만 stderr를 버리는 runner를 씁니다 — **전역 억제가 아니라** 그 조회들에 한정하므로 진짜 오류의 진단은 그대로 보입니다.
18
+ - `commitgate uninstall` 계획이 도입 커밋 revert를 권하면서 그 커밋에 든 **`workflow/.gitignore`가 함께 사라져 기존 티켓의 scratch가 드러난다**는 파급을 예고하지 않았습니다(§3은 증거 보존을 지시하므로 두 안내가 서로를 무효화했습니다). 보존할 증거가 있을 때만 경고와 선택지를 냅니다. 그 밖에 Stage B에 존재하지 않는 `scripts/`를 잔여 후보에서 빼고, `not-installed` 판정에서도 **남아 있는 티켓 증거를 고지**하며, `_npx` 삭제가 CommitGate만이 아니라 **그 사용자의 모든 npx 패키지 캐시**를 지운다는 범위를 명시합니다.
19
+ - 설치 안내의 lockfile 인과 설명("2단계 install이 lockfile을 만든다")을 Stage B 사실대로 고쳤습니다 — lockfile을 바꾸는 것은 `init` **이전**의 `npm i -D commitgate`입니다.
20
+
21
+ - **테스트 픽스처 정리를 결정적으로** (REQ-2026-059). 새 near-e2e 픽스처의 임시 저장소 정리가 git의 **detached auto 유지보수**와 경합해 `ENOTEMPTY`로 간헐 실패했습니다(단언은 전부 통과, ubuntu·Node 20에서만 재현). 픽스처 저장소에서 `gc.auto`·`maintenance.auto`를 모두 끄고(git 버전별 두 경로), 정리에 짧은 재시도를 더했습니다. 단언은 변경하지 않았습니다.
22
+
23
+ 전체 테스트 1709 → 1729.
24
+
5
25
  ## 0.9.9
6
26
 
7
27
  **리뷰 게이트 운영 보강 4종** (REQ-2026-053~056, 소비 저장소 운영감사 후속). 모두 0.9.8 위의 **추가 기능**(신규 명령·additive 필드·opt-in)이라 기존 사용자는 무회귀입니다.
package/bin/init.ts CHANGED
@@ -1378,7 +1378,12 @@ export function installGuidance(r: InitResult): string[] {
1378
1378
  out.push(` ${n++}. 설치분만 stage 하십시오. 전체를 담는 stage(-A / .)는 쓰지 마십시오 — 무관한 변경·.env 가`)
1379
1379
  out.push(` 함께 커밋되고, 이어지는 req:review-codex 가 staged diff 전문을 외부로 전송합니다.`)
1380
1380
  if (r.lockfileRel !== null)
1381
- out.push(` (2단계 install 먼저 실행해야 ${r.lockfileRel}존재합니다. lockfile 만들지 않는 설정이라면 경로는 빼십시오.)`)
1381
+ // 🔴 REQ-2026-058 F-4: Stage B에서파일을 바꾸는 것은 **init 앞의** `npm i -D commitgate`(D14가 요구)이지
1382
+ // 안내 2단계의 `install`이 아니다. init 자신은 devDeps를 주입하지 않으므로 lockfile을 건드리지 않는다.
1383
+ // 이미 커밋했다면 아래 add는 무해한 no-op이고, 아직 미커밋이면 이 경로가 설치 커밋에 함께 담겨야 한다.
1384
+ out.push(
1385
+ ` (${r.lockfileRel} 은 선행 'npm i -D commitgate' 가 갱신한 것입니다 — 이미 커밋했다면 이 경로는 빼도 됩니다. lockfile 을 만들지 않는 설정도 마찬가지입니다.)`,
1386
+ )
1382
1387
  out.push(` git add -- ${toStage.map(quoteForShell).join(' ')}`)
1383
1388
  out.push(` git status # 의도한 것만 staged 인지 눈으로 확인`)
1384
1389
  out.push(` git commit -m "chore: install commitgate"`)
package/bin/uninstall.ts CHANGED
@@ -122,6 +122,11 @@ export interface UninstallPlan {
122
122
  /** 티켓이 실제로 쌓인 증거 디렉터리 — 삭제 금지. */
123
123
  protect: EvidenceDir[]
124
124
  scaffoldCommits: ScaffoldCommit[]
125
+ /**
126
+ * 도입 커밋 revert가 `workflow/.gitignore`를 함께 되돌려 **보존 대상 티켓의 scratch가 드러나는가**
127
+ * (REQ-2026-058 F-6). git 조회가 필요하므로 순수 `buildPlan`은 항상 `false`로 두고 `enrichCommits`가 채운다.
128
+ */
129
+ revertDropsWorkflowGitignore: boolean
125
130
  }
126
131
 
127
132
  const TICKET_DIR_RE = /^REQ-\d{4}-\d+$/
@@ -407,6 +412,7 @@ export function buildPlan(facts: UninstallFacts): UninstallPlan {
407
412
  keep: facts.ambiguous.filter((a) => a.present),
408
413
  protect: facts.evidence.filter((e) => e.ticketCount > 0),
409
414
  scaffoldCommits,
415
+ revertDropsWorkflowGitignore: false, // git 조회는 enrichCommits가 한다(순수 유지)
410
416
  }
411
417
  }
412
418
 
@@ -422,7 +428,10 @@ function enrichCommits(plan: UninstallPlan, git: GitAdapter): UninstallPlan {
422
428
  const found = anchor ? introducingCommit(git, anchor) : null
423
429
  return { sha: c.sha, subject: found?.subject ?? '' }
424
430
  })
425
- return { ...plan, scaffoldCommits }
431
+ // F-6: 보존할 증거가 있을 때만 판정한다 — 증거가 없으면 드러날 scratch도 없어 경고가 소음이다.
432
+ const revertDropsWorkflowGitignore =
433
+ plan.protect.length > 0 && revertDropsWorkflowGitignoreCheck(scaffoldCommits, git)
434
+ return { ...plan, scaffoldCommits, revertDropsWorkflowGitignore }
426
435
  }
427
436
 
428
437
  // ────────────────────────────────────────────────────── 출력 (순수) ──
@@ -448,6 +457,15 @@ export function renderPlan(plan: UninstallPlan): string {
448
457
 
449
458
  if (plan.mode === 'not-installed') {
450
459
  L.push('이 repo에서 CommitGate 설치 흔적을 찾지 못했습니다. 되돌릴 것이 없습니다.')
460
+ // 🔴 REQ-2026-058 F-8: "되돌릴 것이 없다"와 "감사 증거가 남아 있다"는 **동시에 참일 수 있다**.
461
+ // 제거를 마친 뒤의 정상적인 모습이 정확히 이것이다 — 티켓 디렉터리는 보존하도록 안내했기 때문이다.
462
+ // 이 절을 건너뛰면 사용자는 남은 증거의 존재를 모른 채 "아무것도 없다"고 읽는다.
463
+ if (plan.protect.length) {
464
+ L.push('')
465
+ L.push('## 남아 있는 감사 증거 — 삭제하지 마세요')
466
+ for (const e of plan.protect) L.push(` - ${e.path}/ (REQ 티켓 ${e.ticketCount}개 · state.json · approvals.jsonl)`)
467
+ L.push(' 설치는 제거됐지만 이 기록은 남습니다. 이력을 보존할 것인지 함께 정리할 것인지는 직접 판단하세요.')
468
+ }
451
469
  L.push('')
452
470
  L.push(renderNpxSection())
453
471
  return L.join('\n')
@@ -483,7 +501,7 @@ export function renderPlan(plan: UninstallPlan): string {
483
501
  L.push('')
484
502
 
485
503
  L.push('## 4. 되돌리는 방법 (아래 명령은 직접 실행하세요)')
486
- L.push(...renderRevertSection(plan))
504
+ L.push(...renderRevertSection(plan, plan.revertDropsWorkflowGitignore))
487
505
  L.push('')
488
506
 
489
507
  L.push('## 5. 런타임 패키지 제거 (Stage B)')
@@ -491,8 +509,13 @@ export function renderPlan(plan: UninstallPlan): string {
491
509
  L.push('')
492
510
 
493
511
  L.push('## 6. 잔여물 경고')
512
+ // 🔴 REQ-2026-058 F-7: `scripts/`는 **Stage A(vendored) 설치본에만** 존재한다. Stage B init은 실행 코드를
513
+ // 복사하지 않으므로 그 디렉터리가 애초에 생기지 않는다 — 나열하면 없는 잔여물을 찾게 만든다.
514
+ // `unknownKitFiles`/tool 분류에 kit 디렉터리 흔적이 있을 때만 언급한다.
515
+ const hasVendoredDir = facts.tool.some((t) => t.present && t.path.startsWith(`${KIT_SOURCE_DIR_REL}/`)) || facts.unknownKitFiles.length > 0
516
+ const emptyDirs = [...(hasVendoredDir ? [`${KIT_SOURCE_DIR_REL}/`] : []), `${facts.ticketRoot}/`, '.claude/', '.cursor/']
494
517
  L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
495
- L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
518
+ L.push(` 빈 디렉터리(${emptyDirs.join(' · ')})가 파일시스템에 남을 수 있습니다.`)
496
519
  L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
497
520
  L.push('')
498
521
 
@@ -524,7 +547,37 @@ function renderRuntimeRemovalSection(plan: UninstallPlan): string[] {
524
547
  return L
525
548
  }
526
549
 
527
- function renderRevertSection(plan: UninstallPlan): string[] {
550
+ /**
551
+ * revert가 `workflow/.gitignore`를 함께 되돌려 **기존 티켓 scratch가 드러나는** 파급을 경고할 것인가
552
+ * (REQ-2026-058 F-6).
553
+ *
554
+ * 두 조건이 모두 참일 때만 낸다:
555
+ * 1. 그 도입 커밋이 실제로 `KIT_GITIGNORE.dest`를 담고 있다(git이 오라클 — 추측하지 않는다).
556
+ * 2. 보존해야 할 티켓 증거가 있다(`plan.protect`). 증거가 없으면 드러날 scratch도 없어 경고가 소음이다.
557
+ *
558
+ * 이 경고가 없으면 계획이 스스로 모순된다 — §3은 "증거 디렉터리를 보존하라"고 하면서 §4는 그 디렉터리의
559
+ * 무시 규칙을 지우는 명령을 권하게 된다(Nuxt 소비자 감사에서 scratch 5건 노출로 실측).
560
+ */
561
+ function revertDropsWorkflowGitignoreCheck(commits: readonly ScaffoldCommit[], git: GitAdapter): boolean {
562
+ for (const c of commits) {
563
+ let names: string
564
+ try {
565
+ names = git.exec(['show', '--pretty=format:', '--name-only', c.sha])
566
+ } catch {
567
+ continue // 조회 실패는 경고 근거로 삼지 않는다(읽기 전용 planner — 추측 금지)
568
+ }
569
+ if (
570
+ names
571
+ .split('\n')
572
+ .map((l) => l.trim())
573
+ .includes(KIT_GITIGNORE.dest)
574
+ )
575
+ return true
576
+ }
577
+ return false
578
+ }
579
+
580
+ function renderRevertSection(plan: UninstallPlan, gitignoreWarning: boolean): string[] {
528
581
  const L: string[] = []
529
582
  const { facts } = plan
530
583
 
@@ -541,6 +594,13 @@ function renderRevertSection(plan: UninstallPlan): string[] {
541
594
  for (const c of plan.scaffoldCommits) L.push(` ${c.sha} ${c.subject}`)
542
595
  L.push(' 각 커밋의 내용을 확인한 뒤(`git show <sha>`) 되돌릴 범위를 직접 정하세요.')
543
596
  }
597
+ if (gitignoreWarning) {
598
+ L.push(` ⚠️ 이 커밋에는 ${KIT_GITIGNORE.dest} 가 들어 있습니다 — revert하면 그 파일도 함께 사라집니다.`)
599
+ L.push(' 그러면 위 3번의 티켓 안 scratch(codex-response.json · .review-preview.txt · .review-calls.jsonl)가')
600
+ L.push(' 무시되지 않아 `git status`에 드러납니다. 둘 중 하나를 고르십시오:')
601
+ L.push(` - 증거를 계속 쓸 것이면: revert 뒤 ${KIT_GITIGNORE.dest} 를 복원하거나 그 규칙을 루트 .gitignore로 옮기십시오.`)
602
+ L.push(' - 티켓 증거까지 정리할 것이면: 그 scratch 파일들도 함께 지우십시오(그때는 노출이 문제가 아닙니다).')
603
+ }
544
604
  if (plan.mode === 'mixed') L.push(' 일부 파일은 아직 커밋되지 않았습니다 — 아래 미커밋 절차도 함께 보세요.')
545
605
  }
546
606
 
@@ -571,6 +631,9 @@ function renderNpxSection(): string {
571
631
  ' `npx commitgate`는 전역 설치가 아닙니다. 패키지는 npm 캐시의 `_npx/<hash>/`에만 들어갑니다.',
572
632
  ' 확인 : npm ls -g commitgate (비어 있으면 전역 설치 아님)',
573
633
  ' 전역이었다면 : npm uninstall -g commitgate',
634
+ // 🔴 REQ-2026-058 F-9: 이 명령의 범위를 밝힌다 — CommitGate만 지우는 것이 **아니다**.
635
+ ' ⚠️ 아래 명령은 CommitGate 것만이 아니라 이 사용자가 npx 로 실행한 모든 패키지 캐시를 삭제합니다.',
636
+ ' CommitGate만 정리하려면 `_npx/<hash>/` 중 해당 항목만 골라 지우십시오(선택적 · 필수 아님).',
574
637
  ' 캐시에 남은 npx 패키지 정리:',
575
638
  ' Windows (PowerShell) : Remove-Item -Recurse -Force "$(npm config get cache)\\_npx"',
576
639
  ' macOS / Linux : rm -rf "$(npm config get cache)/_npx"',
package/package.json CHANGED
@@ -1,76 +1,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
- }
1
+ {
2
+ "name": "commitgate",
3
+ "version": "0.9.10",
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
+ }
@@ -89,6 +89,20 @@ export interface GitAdapter {
89
89
  export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8'; maxBuffer: number }) => string
90
90
  const defaultGitRunner: GitRunner = (file, args, opts) => execFileSync(file, args, opts)
91
91
 
92
+ /**
93
+ * **부재가 정상인 조회** 전용 runner — git의 stderr를 버린다 (REQ-2026-058 F-5).
94
+ *
95
+ * `git show HEAD:<path>` 류는 "아직 커밋되지 않음"이 **정상 상태**이고 호출부가 `catch → null`로 처리한다.
96
+ * 그런데 `execFileSync`는 기본적으로 자식 stderr를 부모로 흘리므로, 정상 경로에서
97
+ * `fatal: path '…' does not exist in 'HEAD'`가 사용자 화면에 뜬다 — 실패로 오해된다(Nuxt 소비자 감사 실측).
98
+ *
99
+ * 🔴 **전역으로 쓰지 말 것.** 진짜 오류(권한·손상·잠금)의 진단까지 사라진다. `bin/init.ts`의
100
+ * `assertGitWorkTree`가 probe 전용 quiet runner를 쓰는 것과 같은 좁은 용도다.
101
+ * ⚠️ 판정은 바뀌지 않는다 — 실패는 여전히 throw이고 호출부의 `catch`가 부재로 해석한다.
102
+ */
103
+ export const quietGitRunner: GitRunner = (file, args, opts) =>
104
+ execFileSync(file, args, { ...opts, stdio: ['ignore', 'pipe', 'ignore'] })
105
+
92
106
  /** git stdout 상한 — codex 경로(safeSpawnSync)와 동일 64 MiB. 큰 staged diff/status에서 Node 기본 1 MiB의 ENOBUFS throw 방지. */
93
107
  const GIT_MAX_BUFFER = 64 * 1024 * 1024
94
108
 
@@ -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 {
@@ -17,6 +17,13 @@
17
17
  * 그 안에 없다(design 문서=티켓 루트 `0N-*.md`, phase 코드=`workflow/` 밖). 따라서 `responses/`를
18
18
  * 통째로 제외해도 리뷰 대상 손실 없이 pre-call 커밋·evidence-finalize 양쪽에 identity가 불변이다.
19
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
+ *
20
27
  * 🔴 **읽기 전용**: `git ls-files -s`만 쓴다. `git write-tree`는 object DB에 tree를 쓰므로 금지
21
28
  * (`captureIndexHash`와 같은 기법 — req:next가 재계산할 수 있어야 한다).
22
29
  *
@@ -37,16 +44,20 @@ function pathOfLsFilesLine(line: string): string | null {
37
44
  /**
38
45
  * 현재 리뷰의 semantic identity(hex SHA256).
39
46
  *
40
- * = SHA256( 정렬된 `git ls-files -s` 줄들 중, 경로가 `<ticketRel>/responses/` 아래인 줄을 제외 ).
47
+ * = SHA256( 정렬된 `git ls-files -s` 줄들 중, 경로가 `<ticketRel>/responses/` 아래이거나
48
+ * 정확히 `<ticketRel>/state.json`인 줄을 제외 ).
41
49
  *
42
- * 원장·approvals·아카이브가 untracked/modified/committed 어느 상태든 `responses/` 경로라 제외되므로
43
- * identity가 그 변화에 불변이다. 리뷰 대상(문서·코드)과 `responses/` 밖의 non-audit 변경은 반영된다.
50
+ * 원장·approvals·아카이브·작업 상태가 untracked/modified/committed 어느 상태든 제외되므로 identity가
51
+ * 그 변화에 불변이다. 리뷰 대상(문서·코드)과 밖의 non-audit 변경은 반영된다.
44
52
  */
45
53
  export function computeReviewSemanticIdentity(ticketRel: string, gitFn: GitFn): string {
46
54
  const normTicket = ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')
47
55
  // 🔴 ticketRel 경계가 애매하면(빈 값) fail-closed — 잘못된 접두사로 무언가를 조용히 제외하지 않는다.
48
56
  if (normTicket === '') throw new Error('computeReviewSemanticIdentity: ticketRel이 비어 있음(제외 경계 불명 — fail-closed)')
49
57
  const responsesPrefix = `${normTicket}/responses/` // 🔴 정확히 이 티켓의 responses/ 하위만. 다른 workflow 파일·문서·코드 미제외.
58
+ // 🔴 `state.json`은 **정확 일치**로만 제외한다(REQ-2026-057). 접두사 매칭으로 넓히면
59
+ // `state.json.bak` 같은 사용자 파일까지 조용히 사라진다.
60
+ const statePath = `${normTicket}/state.json`
50
61
  const lines = gitFn(['ls-files', '-s'])
51
62
  .split('\n')
52
63
  .map((l) => l.replace(/\r$/, ''))
@@ -54,8 +65,8 @@ export function computeReviewSemanticIdentity(ticketRel: string, gitFn: GitFn):
54
65
  .filter((l) => {
55
66
  const p = pathOfLsFilesLine(l)
56
67
  // 🔴 경로를 못 뽑으면(malformed) **보수적으로 포함**한다 — 모호한 경로를 제외하지 않는다(constraint 3).
57
- // 제외는 명확히 `<ticketRel>/responses/` 하위일 때만.
58
- return p === null ? true : !p.startsWith(responsesPrefix)
68
+ // 제외는 명확히 `<ticketRel>/responses/` 하위이거나 `<ticketRel>/state.json`일 때만.
69
+ return p === null ? true : !p.startsWith(responsesPrefix) && p !== statePath
59
70
  })
60
71
  return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
61
72
  }
@@ -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
+ }
@@ -25,6 +25,8 @@ import {
25
25
  type WorkflowState,
26
26
  } from './review-codex'
27
27
  import { isArchiveFileName } from './lib/scratch'
28
+ // REQ-2026-057: 소비된 상태를 durable checkpoint로 커밋(leaf — 순환 없음).
29
+ import { commitStateCheckpoint } from './lib/state-checkpoint'
28
30
  import { LEDGER_BASENAME } from './lib/review-ledger'
29
31
  import { CLOSE_PROOF_BASENAME, parseCloseProof, deriveBaseState } from './lib/close-proof'
30
32
  import { createEvidencePorts } from './lib/evidence-ports' // 아카이브 파일명 판정의 정본은 scratch(leaf)
@@ -63,12 +65,17 @@ export {
63
65
  type ArchiveInventoryItem,
64
66
  } from './lib/evidence'
65
67
  import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type PackageManager, type ResolvedConfig } from './lib/config'
66
- import { createGitAdapter, safeSpawnSync, type GitAdapter } from './lib/adapters'
68
+ import { createGitAdapter, quietGitRunner, safeSpawnSync, type GitAdapter } from './lib/adapters'
67
69
 
68
70
  // git=GitAdapter 경유(D-017-3), 패키지매니저=config. runDoctor(pnpm/npm 실행)는 cwd=gitRoot 필요(비-git 호출). main()이 loadConfig 후 config.root로 설정.
69
71
  let gitRoot = packageRoot()
70
72
  let pkgManager: PackageManager = DEFAULTS.packageManager
71
73
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
74
+ /**
75
+ * **부재가 정상인 HEAD 조회 전용** 어댑터(REQ-2026-058 F-5). `gitAdapter`와 같은 root를 따라간다.
76
+ * 일반 호출에는 쓰지 않는다 — 진짜 오류의 진단이 사라진다.
77
+ */
78
+ let quietGitAdapter: GitAdapter = createGitAdapter(packageRoot(), quietGitRunner)
72
79
 
73
80
  /**
74
81
  * repo-상대 경로 파일의 sha256(hex). `lib/evidence`는 fs를 모르는 순수 모듈이라 여기서 주입한다.
@@ -479,10 +486,14 @@ function verifyDevCompleteAtHead(ctx: FinalizeCtx): void {
479
486
  throw new Error(`dev-complete HEAD 재검증 실패: 발행 후 파생 상태가 dev-complete가 아니다(${state})`)
480
487
  }
481
488
 
482
- /** `HEAD:<repoRel>` blob 텍스트(없으면 null). */
489
+ /**
490
+ * `HEAD:<repoRel>` blob 텍스트(없으면 null).
491
+ * 🔴 부재가 정상이므로 quiet 어댑터를 쓴다 — git의 `fatal: … does not exist in 'HEAD'`가 정상 경로에서
492
+ * 사용자 화면에 뜨지 않게 한다(REQ-2026-058 F-5). 판정(`catch → null`)은 그대로다.
493
+ */
483
494
  function headBlobText(repoRel: string): string | null {
484
495
  try {
485
- return git(['show', `HEAD:${repoRel}`])
496
+ return quietGitAdapter.exec(['show', `HEAD:${repoRel}`])
486
497
  } catch {
487
498
  return null
488
499
  }
@@ -511,6 +522,7 @@ interface FinalizeCtx {
511
522
  export function __setGitForTest(root: string): void {
512
523
  gitRoot = root
513
524
  gitAdapter = createGitAdapter(root)
525
+ quietGitAdapter = createGitAdapter(root, quietGitRunner) // 같은 root를 따라가야 HEAD 조회가 엉뚱한 저장소를 보지 않는다
514
526
  }
515
527
 
516
528
  export function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
@@ -564,7 +576,34 @@ export function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
564
576
  console.log('[req:commit] evidence 이미 finalize됨(멱등 skip) — 소비만 수행')
565
577
  }
566
578
  // 소비(마지막) — commit_allowed=false·approved_diff_hash=null·pending 마커 제거.
567
- writeState(ctx.ticketDir, consumeState(ctx.state, { sourceCommitSha: ctx.sourceSha, consumedAt: new Date().toISOString() }))
579
+ const consumed = consumeState(ctx.state, { sourceCommitSha: ctx.sourceSha, consumedAt: new Date().toISOString() })
580
+ writeState(ctx.ticketDir, consumed)
581
+
582
+ // ── REQ-2026-057: 소비 상태 durable checkpoint ──
583
+ // 🔴 **순서를 바꾸지 않는다**(설계 DEC-2). consume을 evidence 커밋 앞으로 옮겨 한 커밋에 담으면,
584
+ // 그 커밋이 실패했을 때 `pending_evidence_for`·`approval_evidence`가 이미 제거돼 `--finalize` 복구가
585
+ // 근거를 잃는다. 그래서 소비를 마지막에 두고 **그 결과만** 별도 pathspec 커밋으로 내구화한다.
586
+ //
587
+ // 🔴 실패를 삼킨다 — 커밋은 이미 성공했다. 여기서 throw하면 사용자는 커밋이 실패한 것으로 오해한다.
588
+ // checkpoint가 없으면 이 REQ 이전과 같은 상태(dirty state.json)로 남고, 재실행이 흡수한다(멱등).
589
+ try {
590
+ if (
591
+ commitStateCheckpoint({
592
+ root: ctx.rootForClose,
593
+ ticketRel: ctx.ticketRel,
594
+ ticketId: String(ctx.state.id ?? ''),
595
+ state: consumed,
596
+ reason: `phase ${ctx.ev.phase_id ?? ''} 소비`,
597
+ gitFn: git,
598
+ })
599
+ )
600
+ console.log('[req:commit] state checkpoint 커밋(소비 상태).')
601
+ } catch (err) {
602
+ console.warn(
603
+ `[req:commit] ⚠️ state checkpoint 커밋 실패(커밋·증거는 유효): ${err instanceof Error ? err.message : String(err)}\n` +
604
+ ` ${ctx.ticketRel}/state.json 이 미커밋으로 남아 다음 req:new 가 막힐 수 있습니다 — 원인 해소 후 재실행하십시오.`,
605
+ )
606
+ }
568
607
  }
569
608
 
570
609
  /**
@@ -611,6 +650,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
611
650
  gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
612
651
  pkgManager = cfg.packageManager
613
652
  gitAdapter = createGitAdapter(cfg.root)
653
+ quietGitAdapter = createGitAdapter(cfg.root, quietGitRunner) // HEAD 부재 조회 전용(F-5)
614
654
  const { ticketDir, doctorArgs } = resolveCommitTarget(opts, cfg)
615
655
  const { run, message, messageFile, finalize, finalizeDesign } = opts
616
656
  const state = loadState(ticketDir)
@@ -343,8 +343,27 @@ function reviewCmd(pm: PackageManager, target: NextTarget, kind: ReviewKind, pha
343
343
  return buildScriptInvocation(pm, 'req:review-codex', args).join(' ')
344
344
  }
345
345
 
346
+ /**
347
+ * 커밋 메시지 자리표시자 — **사람 승인 경로와 LOW 자동 경로가 공유**한다(REQ-2026-058 F-3).
348
+ *
349
+ * `req:commit`은 메시지 없이는 fail-closed로 죽고, read-only인 `req:next`는 메시지를 **합성할 수 없다**
350
+ * (합성해서도 안 된다 — 커밋 메시지는 사람·Builder의 판단이다). 그래서 자리표시자를 실어 보내고
351
+ * 실행자가 그 자리를 메운다. 두 경로가 문자열을 각자 들고 있으면 갈라지므로 상수 하나를 공유한다.
352
+ */
353
+ export const COMMIT_MESSAGE_PLACEHOLDER = '"<이 phase의 conventional 커밋 메시지>"'
354
+
355
+ /**
356
+ * 사람 승인(AWAIT_HUMAN) 경로의 커밋 명령.
357
+ *
358
+ * 🔴 자리표시자를 **반드시** 싣는다. 이 문자열은 "승인 후 실행: $ …"로 제시되므로 **그대로 실행 가능**해야
359
+ * 한다 — 없으면 doctor 17개 체크를 모두 통과한 뒤 `커밋 메시지 필요`로 죽어, 사용자는 게이트가 막은
360
+ * 것으로 오해한다(REQ-2026-058 F-3, Nuxt 소비자 감사 실측).
361
+ *
362
+ * ⚠️ `autoCommitCmd`와 합치지 않는다 — 승인 주체가 다르고(정책 자동 vs 사람 확인) 앞으로 인자가 갈라질 수
363
+ * 있다. 공유해야 하는 것은 자리표시자 문자열 하나뿐이고, 그것은 상수로 뽑았다.
364
+ */
346
365
  function commitCmd(pm: PackageManager, target: NextTarget): string {
347
- return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run']).join(' ')
366
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run', '-m', COMMIT_MESSAGE_PLACEHOLDER]).join(' ')
348
367
  }
349
368
 
350
369
  /**
@@ -353,7 +372,7 @@ function commitCmd(pm: PackageManager, target: NextTarget): string {
353
372
  * 자리표시자를 실제 conventional 메시지로 바꿔 실행한다(AGENT 단계에서 `git add` 대상을 고르는 것과 동형).
354
373
  */
355
374
  function autoCommitCmd(pm: PackageManager, target: NextTarget): string {
356
- return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run', '-m', '"<이 phase의 conventional 커밋 메시지>"']).join(' ')
375
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run', '-m', COMMIT_MESSAGE_PLACEHOLDER]).join(' ')
357
376
  }
358
377
 
359
378
  /**
@@ -16,7 +16,7 @@ import { writeFileSync } from 'node:fs'
16
16
  import { join, relative } from 'node:path'
17
17
  import { pathToFileURL } from 'node:url'
18
18
  import { loadConfig, packageRoot } from './lib/config'
19
- import { createGitAdapter, type GitAdapter } from './lib/adapters'
19
+ import { createGitAdapter, quietGitRunner, type GitAdapter } from './lib/adapters'
20
20
  import { createEvidencePorts } from './lib/evidence-ports'
21
21
  import { verifyCommittedEvidenceIntegrity } from './lib/evidence'
22
22
  import { parseCloseProof, appendCloseProofRow, closeProofPath, type CloseProofRow } from './lib/close-proof'
@@ -25,13 +25,18 @@ import { listHeadTicketIds } from './lib/intake'
25
25
  import { isValidHumanResolution } from './review-codex'
26
26
 
27
27
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
28
+ /**
29
+ * **부재가 정상인 HEAD 조회 전용** 어댑터(REQ-2026-058 F-5). 이 명령의 HEAD 조회는 "아직 없음"이 정상이라
30
+ * git stderr의 `fatal: … does not exist in 'HEAD'`가 사용자에게 실패로 보인다. 판정은 그대로 `catch → null`.
31
+ */
32
+ let quietGitAdapter: GitAdapter = createGitAdapter(packageRoot(), quietGitRunner)
28
33
  function git(args: string[]): string {
29
34
  return gitAdapter.exec(args)
30
35
  }
31
- /** `HEAD:<repoRel>` blob 텍스트(없으면 null). */
36
+ /** `HEAD:<repoRel>` blob 텍스트(없으면 null). 부재가 정상이므로 quiet 어댑터를 쓴다(F-5). */
32
37
  function headBlob(repoRel: string): string | null {
33
38
  try {
34
- return git(['show', `HEAD:${repoRel}`])
39
+ return quietGitAdapter.exec(['show', `HEAD:${repoRel}`])
35
40
  } catch {
36
41
  return null
37
42
  }
@@ -129,6 +134,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
129
134
  if (!o.reqId) throw new Error('REQ 필요 (예: req:reconstruct 2026-029)')
130
135
  const cfg = loadConfig({ root: o.root })
131
136
  gitAdapter = createGitAdapter(cfg.root)
137
+ quietGitAdapter = createGitAdapter(cfg.root, quietGitRunner) // HEAD 부재 조회 전용(F-5)
132
138
  const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
133
139
  const ticketDir = join(cfg.workflowDirAbs, reqId)
134
140
  const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
@@ -149,7 +155,8 @@ export function main(argv: string[] = process.argv.slice(2)): void {
149
155
  throw new Error(`${reqId}: HEAD close-proof 손상 — 복원 거부: ${parsed.problems.slice(0, 3).join('; ')}`)
150
156
 
151
157
  // 3. successor 증거 수집(HEAD tree) → 매트릭스(순수) 판정.
152
- const successors = collectSuccessorEvidence(workflowDirRel, reqId, (a) => git(a))
158
+ // 🔴 quiet 어댑터를 넘긴다 함수의 git 호출은 전부 **HEAD 조회**이고 부재가 정상이다(F-5).
159
+ const successors = collectSuccessorEvidence(workflowDirRel, reqId, (a) => quietGitAdapter.exec(a))
153
160
  const plan = planReconstruction({ ticketId: reqId, existingRows: parsed.rows, successors })
154
161
  console.log(renderPlan(reqId, plan))
155
162
 
@@ -55,6 +55,8 @@ import { closeProofPath, appendCloseProofRow, type CloseProofRow } from './lib/c
55
55
  import { summarizeLockfileDiff } from './lib/lockfile-diff'
56
56
  import { parseStatusZ, entryPaths, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
57
57
  import { isArchiveFileName, isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
58
+ // REQ-2026-057: 상태 직렬화 단일 지점 + durable checkpoint(leaf — 여기서 값으로 import해도 순환 없음).
59
+ import { commitStateCheckpoint, serializeState } from './lib/state-checkpoint'
58
60
 
59
61
  // codex JSONL thread 파싱은 어댑터 모듈 정본(re-export로 기존 import 호환).
60
62
  export { parseThreadId } from './lib/adapters'
@@ -1683,9 +1685,14 @@ export function processResponse(args: {
1683
1685
  return { ok, errors, nextState, verdict }
1684
1686
  }
1685
1687
 
1686
- /** state.json 기록 — UTF-8(BOM 없음), 2-space + 끝 개행. */
1688
+ /**
1689
+ * state.json 기록 — UTF-8(BOM 없음), 2-space + 끝 개행.
1690
+ *
1691
+ * 🔴 직렬화는 `lib/state-checkpoint`의 `serializeState`가 정본이다(REQ-2026-057). checkpoint 커밋이
1692
+ * "디스크 내용 == 도구가 쓴 상태"를 바이트로 대조하므로, 두 곳이 갈라지면 그 대조가 항상 실패한다.
1693
+ */
1687
1694
  export function writeState(ticketDir: string, state: WorkflowState): void {
1688
- writeFileSync(join(ticketDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
1695
+ writeFileSync(join(ticketDir, 'state.json'), serializeState(state), 'utf8')
1689
1696
  }
1690
1697
 
1691
1698
  function verdictHasFindings(verdict: Verdict): boolean {
@@ -2468,6 +2475,20 @@ function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void
2468
2475
  })
2469
2476
  if (r.outcome !== 'already-durable')
2470
2477
  console.log('[req:review-codex] design 승인 증거를 커밋했습니다(아카이브·approvals.jsonl).')
2478
+ // REQ-2026-057 DEC-5: 승인 상태 durable checkpoint — **증거 커밋 직후**에만 낸다.
2479
+ // 증거 커밋이 실패했다면(위 throw) 여기 도달하지 않는다 — 증거 없이 "design_approved=true"만
2480
+ // 커밋되어 상태가 증거를 앞서는 일을 만들지 않는다. 실패 정책도 같은 catch를 공유한다(승인 판정 불변).
2481
+ if (
2482
+ commitStateCheckpoint({
2483
+ root: cfg.root,
2484
+ ticketRel: ticketRelForEvidence,
2485
+ ticketId: String(persistedState.id ?? ''),
2486
+ state: persistedState,
2487
+ reason: 'design 승인',
2488
+ gitFn: git,
2489
+ })
2490
+ )
2491
+ console.log('[req:review-codex] state checkpoint 커밋(design 승인).')
2471
2492
  } catch (err) {
2472
2493
  console.warn(
2473
2494
  `[req:review-codex] ⚠️ design 승인 증거 커밋 실패(승인 자체는 유효): ${err instanceof Error ? err.message : String(err)}