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.
@@ -12,7 +12,10 @@ import { resolve, join, relative } from 'node:path'
12
12
  import { pathToFileURL } from 'node:url'
13
13
  import { createHash } from 'node:crypto'
14
14
  import { parseStatusZ, entryPaths, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
15
- import { isArchiveFileName, isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
15
+ import { isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
16
+ // REQ-2026-048 phase-1: confinement 술어는 leaf `lib/evidence.ts`가 정본 — 여기서 재수출(기존 경로 보존).
17
+ import { isConfinedArchivePath } from './lib/evidence'
18
+ export { isConfinedArchivePath } from './lib/evidence'
16
19
  import {
17
20
  loadState,
18
21
  validateVerdict,
@@ -20,11 +23,12 @@ import {
20
23
  findUnstagedOrUntracked,
21
24
  captureDesignBinding,
22
25
  designDocPaths,
26
+ REVIEW_CALL_LOG_REL,
23
27
  type WorkflowState,
24
28
  type Verdict,
25
29
  type ApprovalEvidence,
26
30
  } from './review-codex'
27
- import { loadConfig, packageRoot, stripBom, DEFAULTS, type ResolvedConfig } from './lib/config'
31
+ import { loadConfig, packageRoot, stripBom, DEFAULTS, type ResolvedConfig, type PackageManager } from './lib/config'
28
32
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
29
33
  import { missingQuickstartFiles } from '../../bin/quickstart'
30
34
 
@@ -98,6 +102,18 @@ export interface DoctorInputs {
98
102
  // undefined = 미계산(2-arg/legacy) → OK. [] = 없음/최신/대상없음 → OK. 비어있지 않음 → WARN.
99
103
  // dev/dogfood(packageRootDiffers===false)면 D20처럼 skip. optional이어야 테스트 base 리터럴이 안 깨진다.
100
104
  quickstartMissing?: string[]
105
+ // D22(REQ-2026-047): repo-root 런타임 스크래치 경로 중 **ignore도 tracked도 아닌** 것(= 다음 review가 만들면 D10이 막는 것).
106
+ // 런타임이 소비 repo 루트에 만드는 스크래치(현재 `workflow/.review-calls.jsonl`)는 티켓 밖이라 `/REQ-*/` 앵커에
107
+ // 걸리지 않고 `reviewScratchPaths` 허용목록에도 없다 → **gitignore가 유일한 방어**다. 0.9.6 이하 설치본은 배포
108
+ // 템플릿에 그 규칙이 없어, 첫 리뷰 뒤 D10 FAIL로 커밋이 전부 막혔다.
109
+ // undefined = 미계산(2-arg/legacy) → OK. [] = 전부 보호됨 → OK. 비어있지 않음 → WARN.
110
+ // dev/dogfood(packageRootDiffers===false)면 D20/D21처럼 skip. optional이어야 테스트 base 리터럴이 안 깨진다.
111
+ repoRootScratchUnprotected?: string[]
112
+ // D23(REQ-2026-056): frozen-lockfile 위생. 감지된 PM의 lockfile이 없거나 untracked면 재현 가능한 설치
113
+ // (`<pm> ci`/`--frozen-lockfile`)가 불가하다. undefined = 미계산(2-arg/legacy) → OK.
114
+ // 'no-package-json'/'ok' → OK. 'missing'/'untracked' → **WARN**(FAIL 아님 — D19~D22 근거 동일).
115
+ // optional이어야 테스트 base 리터럴이 안 깨진다.
116
+ lockfileStatus?: 'ok' | 'missing' | 'untracked' | 'no-package-json'
101
117
  }
102
118
 
103
119
  /**
@@ -190,20 +206,9 @@ export interface ArchiveCheck {
190
206
  structureOk: boolean
191
207
  }
192
208
 
193
- /**
194
- * evidence `response_path`가 **현재 티켓 `responses/` 직계 아카이브**인지(D-016 confinement).
195
- * 절대경로·`..`·다른 티켓·중첩경로·`approvals.jsonl` 등 비아카이브는 거부. ticketRel 미지정 시 false(fail-closed).
196
- */
197
- export function isConfinedArchivePath(p: string, ticketRel: string | undefined): boolean {
198
- if (!ticketRel || typeof p !== 'string' || !p) return false
199
- const norm = p.replace(/\\/g, '/')
200
- if (norm.includes('..') || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm)) return false
201
- const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
202
- if (!norm.startsWith(prefix)) return false
203
- const name = norm.slice(prefix.length)
204
- if (!name || name.includes('/')) return false
205
- return isArchiveFileName(name)
206
- }
209
+ // `isConfinedArchivePath`는 REQ-2026-048 phase-1에서 `lib/evidence.ts`로 이동했다 — 매니페스트 검증이
210
+ // 같은 술어를 쓰는데 여기 두면 `lib/evidence → req-doctor` 런타임 간선이 생겨 순환이 된다. 아래 re-export로
211
+ // 기존 import 경로(`from './req-doctor'`)를 보존한다.
207
212
 
208
213
  /**
209
214
  * 승인 증거(evidence)와 그 아카이브 파일의 정합 문제 목록(순수, A2/D-016-5).
@@ -482,6 +487,52 @@ export function runChecks(inp: DoctorInputs): Check[] {
482
487
  })
483
488
  }
484
489
 
490
+ // D22(REQ-2026-047): repo-root 런타임 스크래치가 ignore도 tracked도 아님 → 다음 review 뒤 D10이 커밋을 막는다.
491
+ //
492
+ // 🔴 **level 상한은 WARN — 절대 FAIL이 아니다**(D19:425-428·D20:443-447·D21과 동일 근거). `req:commit`이 이 doctor를
493
+ // exit≠0에 throw하는 하드 게이트로 spawn하므로, FAIL이면 백필 전까지 소비자의 모든 커밋이 벽돌이 된다.
494
+ // 더구나 이 드리프트는 **이미** D10 FAIL로 발현한다 — 신규 진단이 차단을 만드는 것이 아니라, 불투명한
495
+ // `D10: unstaged/untracked workflow/.review-calls.jsonl`을 **행동 가능한 안내로 번역**하는 것이 역할이다.
496
+ //
497
+ // tracked인 경우는 여기서 경고하지 않는다(이미 커밋된 상태 = 다른 문제). 그 복구는 `git rm --cached` 절차로
498
+ // troubleshooting 문서가 다룬다 — ignore 규칙만 넣어서는 tracked 파일이 빠지지 않기 때문이다.
499
+ if (inp.packageRootDiffers === false) {
500
+ c.push({ id: 'D22', level: 'OK', msg: 'repo-root 스크래치 보호 점검 불요(dev repo/dogfood — packageRoot === config root)' })
501
+ } else if (inp.repoRootScratchUnprotected === undefined) {
502
+ c.push({ id: 'D22', level: 'OK', msg: 'repo-root 스크래치 보호 점검 불요(2-arg/미계산)' })
503
+ } else if (inp.repoRootScratchUnprotected.length === 0) {
504
+ c.push({ id: 'D22', level: 'OK', msg: 'repo-root 런타임 스크래치가 모두 ignore(또는 tracked)됨' })
505
+ } else {
506
+ c.push({
507
+ id: 'D22',
508
+ level: 'WARN',
509
+ msg:
510
+ `${inp.repoRootScratchUnprotected.join(', ')} 이(가) gitignore로 무시되지 않습니다 — ` +
511
+ '다음 review가 이 파일을 만들면 **D10이 FAIL하여 커밋이 막힙니다**. ' +
512
+ '`commitgate sync --gitignore --apply` 로 배포 템플릿의 누락 규칙을 보강하세요(기존 행은 변경하지 않습니다, REQ-2026-047).',
513
+ })
514
+ }
515
+
516
+ // D23(REQ-2026-056): frozen-lockfile 위생 진단.
517
+ //
518
+ // 🔴 **level 상한은 WARN — 절대 FAIL이 아니다**(D19~D22와 동일 근거). `req:commit`이 doctor를 하드 게이트로
519
+ // spawn하므로 FAIL이면 lockfile 없는 프로젝트의 모든 커밋이 벽돌이 된다. lockfile ↔ package.json 동기
520
+ // 여부는 검사하지 않는다(PM 실행 없이 신뢰 불가) — 존재·tracked 위생만.
521
+ if (inp.lockfileStatus === undefined || inp.lockfileStatus === 'ok') {
522
+ c.push({ id: 'D23', level: 'OK', msg: inp.lockfileStatus === undefined ? 'lockfile 위생 점검 불요(미계산)' : 'lockfile 존재·git-tracked — 재현 가능한 설치(--frozen-lockfile) 가능' })
523
+ } else if (inp.lockfileStatus === 'no-package-json') {
524
+ c.push({ id: 'D23', level: 'OK', msg: 'lockfile 위생 점검 불요(package.json 없음)' })
525
+ } else {
526
+ c.push({
527
+ id: 'D23',
528
+ level: 'WARN',
529
+ msg:
530
+ inp.lockfileStatus === 'missing'
531
+ ? '감지된 패키지 매니저의 lockfile이 없습니다 — 재현 가능한 설치(`<pm> ci` / `--frozen-lockfile`)가 불가합니다. lockfile을 생성·커밋하세요.'
532
+ : 'lockfile이 git-tracked가 아닙니다(untracked) — 재현 가능한 설치가 불가합니다. lockfile을 커밋하세요.',
533
+ })
534
+ }
535
+
485
536
  return c
486
537
  }
487
538
 
@@ -583,6 +634,38 @@ export function safeSha256(abs: string): string | null {
583
634
  }
584
635
  }
585
636
 
637
+ /**
638
+ * D22(REQ-2026-047): repo-root 런타임 스크래치 중 **ignore도 tracked도 아닌** 경로.
639
+ *
640
+ * 판정은 **로컬 git 상태 그대로**다(전역 excludes 포함) — D10이 보는 `git status`와 같은 기준이어야
641
+ * "다음 review 뒤 D10이 막는다"는 예측이 맞는다. 파일이 아직 없어도 `check-ignore`는 패턴 매칭이라
642
+ * 동작한다(그래서 **첫 리뷰 전에 미리** 경고할 수 있다 — 이 검사의 존재 이유).
643
+ *
644
+ * 읽기 전용 advisory라 어떤 오류도 삼킨다(조회 실패 = 보호됨으로 간주 → WARN 안 냄. fail-safe: 게이트를 막지 않는다).
645
+ */
646
+ export function unprotectedRepoRootScratch(paths: readonly string[], gitFn: (a: string[]) => string): string[] {
647
+ const out: string[] = []
648
+ for (const p of paths) {
649
+ let ignored = false
650
+ let tracked = false
651
+ try {
652
+ gitFn(['check-ignore', '-q', '--', p])
653
+ ignored = true
654
+ } catch {
655
+ ignored = false
656
+ }
657
+ if (!ignored) {
658
+ try {
659
+ tracked = gitFn(['ls-files', '--', p]).trim() !== ''
660
+ } catch {
661
+ tracked = true // 조회 불가 → 보호됨으로 간주(경고하지 않음)
662
+ }
663
+ }
664
+ if (!ignored && !tracked) out.push(p)
665
+ }
666
+ return out
667
+ }
668
+
586
669
  /** package.json의 version 문자열. 부재·파손 시 null(D20 WARN 메시지용 — 없어도 무해). */
587
670
  function safeReadVersion(pkgAbs: string): string | null {
588
671
  try {
@@ -593,6 +676,29 @@ function safeReadVersion(pkgAbs: string): string | null {
593
676
  }
594
677
  }
595
678
 
679
+ /** PM별 기대 lockfile 후보(npm은 둘 다 유효). */
680
+ const LOCKFILES_FOR_PM: Record<PackageManager, string[]> = {
681
+ npm: ['package-lock.json', 'npm-shrinkwrap.json'],
682
+ pnpm: ['pnpm-lock.yaml'],
683
+ yarn: ['yarn.lock'],
684
+ }
685
+
686
+ /**
687
+ * frozen-lockfile 위생 판정(D23·REQ-2026-056). package.json 없으면 점검 불요. PM 기대 lockfile이 하나라도
688
+ * 존재+tracked면 ok, 존재하나 전부 untracked면 untracked, 하나도 없으면 missing.
689
+ * 🔴 lockfile↔package.json **동기 검사는 안 한다**(PM 실행 없이 신뢰 불가) — 존재·tracked 위생만.
690
+ */
691
+ export function lockfileHygiene(
692
+ root: string,
693
+ pm: PackageManager,
694
+ isTracked: (rel: string) => boolean,
695
+ ): 'ok' | 'missing' | 'untracked' | 'no-package-json' {
696
+ if (!existsSync(join(root, 'package.json'))) return 'no-package-json'
697
+ const present = LOCKFILES_FOR_PM[pm].filter((f) => existsSync(join(root, f)))
698
+ if (present.length === 0) return 'missing'
699
+ return present.some((f) => isTracked(f)) ? 'ok' : 'untracked'
700
+ }
701
+
596
702
  export function main(argv: string[] = process.argv.slice(2)): void {
597
703
  const opts = parseArgs(argv)
598
704
  const cfg = loadConfig({ root: opts.root })
@@ -699,6 +805,18 @@ export function main(argv: string[] = process.argv.slice(2)): void {
699
805
  schemaPathIsDefault: cfg.schemaPathAbs === resolve(cfg.root, DEFAULTS.schemaPath), // 정규화 절대경로 비교(동치 상대경로 포함)
700
806
  installedVersion: safeReadVersion(join(packageRoot(), 'package.json')),
701
807
  quickstartMissing: missingQuickstartFiles(cfg.root),
808
+ // D22(REQ-2026-047): 현재 repo-root 런타임 스크래치 축은 review-call 측정 로그 1건.
809
+ // 새 축이 생기면 이 배열에 추가하고 packed-consumer smoke 단언도 함께 늘린다(docs 인벤토리 표의 유지 규칙).
810
+ repoRootScratchUnprotected: unprotectedRepoRootScratch([REVIEW_CALL_LOG_REL], git),
811
+ // D23(REQ-2026-056): frozen-lockfile 위생(존재·tracked). tracked 판정은 read-only `git ls-files`.
812
+ lockfileStatus: lockfileHygiene(cfg.root, cfg.packageManager, (rel) => {
813
+ try {
814
+ git(['ls-files', '--error-unmatch', '--', rel])
815
+ return true
816
+ } catch {
817
+ return false
818
+ }
819
+ }),
702
820
  }
703
821
 
704
822
  const checks = runChecks(inp)
@@ -14,11 +14,12 @@
14
14
  import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
15
15
  import { join, relative } from 'node:path'
16
16
  import { pathToFileURL } from 'node:url'
17
- import { writeState, loadState, resolveSuccessorLineage, type WorkflowState, type SuccessorOf } from './review-codex'
17
+ import { writeState, loadState, resolveSuccessorLineage, durableParentSeriesTerminal, type WorkflowState, type SuccessorOf } from './review-codex'
18
18
  import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
19
19
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
20
20
  import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
21
21
  import { isToolOutputScratch } from './lib/scratch'
22
+ import { scanIntake, type IntakeTicketResult } from './lib/intake'
22
23
 
23
24
  // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
24
25
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
@@ -71,6 +72,24 @@ export function nextStepHint(pm: PackageManager, reqId: string): string {
71
72
  return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
72
73
  }
73
74
 
75
+ /**
76
+ * intake 게이트 결과 요약(순수). 차단 티켓 목록·복구 안내 + legacy 표시 문구.
77
+ * 🔴 `blocked`가 비어있지 않으면 호출부가 fail-closed(throw)한다 — 이 함수는 문구만 만든다.
78
+ */
79
+ export function renderIntakeSummary(tickets: readonly IntakeTicketResult[]): string {
80
+ const blocked = tickets.filter((t) => t.verdict === 'block')
81
+ const legacy = tickets.filter((t) => t.verdict === 'legacy')
82
+ const lines: string[] = []
83
+ if (blocked.length) {
84
+ lines.push('🔴 미종결 durable 티켓이 있어 새 REQ를 만들 수 없습니다(HEAD 커밋 증거 기준):')
85
+ for (const t of blocked) lines.push(` - ${t.ticketId}: ${t.baseState} — ${t.reason}`)
86
+ lines.push(' → 위 티켓을 완료(모든 phase 커밋)·종결하거나 복구한 뒤 다시 시도하세요.')
87
+ lines.push(' 복구가 필요하면 `req:commit <REQ> --finalize --run`(승인 tree==HEAD tree인 미완 evidence 내구화) 등을 사용합니다.')
88
+ }
89
+ if (legacy.length) lines.push(`ℹ️ legacy 티켓(표시만, 차단 안 함): ${legacy.map((t) => t.ticketId).join(', ')}`)
90
+ return lines.join('\n')
91
+ }
92
+
74
93
  export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH', successorOf?: SuccessorOf): WorkflowState {
75
94
  return {
76
95
  id: reqId,
@@ -89,6 +108,9 @@ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | '
89
108
  phases: [],
90
109
  // REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
91
110
  approval_evidence_required: true,
111
+ // REQ-2026-048 DEC-4: design 증거 내구성 marker. **이 스캐폴드가 커밋되므로 HEAD blob에 영속**한다 —
112
+ // req:next의 DONE 게이트가 워킹 캐시가 아니라 그 커밋본을 읽어 신규/legacy를 판별한다(캐시 소실로 우회 불가).
113
+ evidence_durability_required: true,
92
114
  // REQ-2026-027 D1: review series 모델 버전. 첫 리뷰 전에도 존재해 "새 ticket(레코드 없음)"과
93
115
  // "legacy(필드 부재)"를 구분한다. 필드 부재 = legacy → 새 재리뷰 시 AWAIT_HUMAN/throw(자동 초기화 금지).
94
116
  review_series_model_version: 1,
@@ -171,6 +193,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
171
193
  // REQ-2026-029 D3: --successor-of lineage 해소. **branch 생성·mkdir 前**에 검증(design-r01 observation) —
172
194
  // 부모 없음·replace 기록 없음·형식 위반이면 여기서 throw해 티켓이 생성되지 않는다(R6 fail-closed).
173
195
  let successorOf: SuccessorOf | undefined
196
+ let parentTerminal: { parentId: string; parentTicketRel: string; parentState: WorkflowState } | undefined
174
197
  if (o.successorOf !== null) {
175
198
  const parentId = o.successorOf.startsWith('REQ-') ? o.successorOf : `REQ-${o.successorOf}`
176
199
  const parentDir = join(cfg.workflowDirAbs, parentId)
@@ -182,8 +205,15 @@ export function main(argv: string[] = process.argv.slice(2)): void {
182
205
  }
183
206
  // recorded_at은 자식 생성 시각(부모 값 아님). dry-run에선 검증만 하고 값은 버린다.
184
207
  successorOf = resolveSuccessorLineage(parentState, parentId, new Date().toISOString())
208
+ parentTerminal = { parentId, parentTicketRel: relative(cfg.root, parentDir).replace(/\\/g, '/'), parentState }
185
209
  }
186
210
 
211
+ // 🔴 REQ-2026-052 phase-3b: intake 게이트 — HEAD-committed durable 증거만으로 기존 티켓을 스캔(read-only).
212
+ // 대체될 부모(successor)는 정규 replace 흐름으로 종결되므로 제외한다(부모 replace 검증은 위에서 끝났다).
213
+ // 미종결(developing/needs-recovery)·손상 티켓이 있으면 아래에서 fail-closed. 스캔 자체는 write 없음.
214
+ const intake = scanIntake(cfg.root, ticketRootRel, (args) => git(args), parentTerminal?.parentId ?? null)
215
+ const intakeSummary = renderIntakeSummary(intake.tickets)
216
+
187
217
  if (!o.run) {
188
218
  console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
189
219
  console.log(` REQ : ${reqId}`)
@@ -191,9 +221,16 @@ export function main(argv: string[] = process.argv.slice(2)): void {
191
221
  console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
192
222
  console.log(` risk : ${o.risk}`)
193
223
  if (successorOf) console.log(` successor_of : ${successorOf.req_id} (부모 ${successorOf.parent_attempts_total}회 · replace 승인 확인)`)
224
+ if (intakeSummary) console.log(intakeSummary)
225
+ if (intake.blocked.length) console.log(' ⚠️ 위 차단 사유로 --run은 실패합니다.')
194
226
  return
195
227
  }
196
228
 
229
+ // 🔴 intake 게이트 강제(write 전) — 미종결 durable 티켓이 있으면 티켓 생성 없이 즉시 fail-closed.
230
+ // durableParentSeriesTerminal·checkout·티켓 커밋보다 **먼저** 검사해, 차단 시 어떤 write도 하지 않는다.
231
+ if (intake.blocked.length) throw new Error(renderIntakeSummary(intake.tickets))
232
+ if (intakeSummary) console.log(intakeSummary) // legacy 표시(차단 없음).
233
+
197
234
  // 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지).
198
235
  // 단, gitignore 규칙이 없는 레거시 설치본도 기존 티켓의 순수 untracked 도구 산출물만 좁게 허용한다(D6·D7).
199
236
  const dirtyEntries = findReqNewDirtyEntries(git([...STATUS_Z_ARGS]), ticketRootRel)
@@ -209,6 +246,21 @@ export function main(argv: string[] = process.argv.slice(2)): void {
209
246
  const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
210
247
  if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
211
248
 
249
+ // 🔴 REQ-2026-052 test #3: 부모의 replace/human-resolution 종결을 durable화(ledger + series-terminal
250
+ // close proof를 커밋). 브랜치 체크아웃 **전**(현재 브랜치에서) — clean tree는 위에서 확인됐고, 이 커밋이
251
+ // 부모 종결을 HEAD에 남긴다. 멱등이라 재실행에 중복 없음.
252
+ if (parentTerminal) {
253
+ const committed = durableParentSeriesTerminal({
254
+ root: cfg.root,
255
+ gitFn: (args) => git(args),
256
+ parentTicketRel: parentTerminal.parentTicketRel,
257
+ parentState: parentTerminal.parentState,
258
+ parentId: parentTerminal.parentId,
259
+ nowIso: new Date().toISOString(),
260
+ })
261
+ if (committed) console.log(` 부모 ${parentTerminal.parentId} series-terminal close proof·ledger 커밋(durable 종결)`)
262
+ }
263
+
212
264
  git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
213
265
  mkdirSync(ticketDir, { recursive: true })
214
266
  writeState(ticketDir, buildInitialState(reqId, branch, o.risk, successorOf))
@@ -21,8 +21,11 @@ import { resolve, join, relative } from 'node:path'
21
21
  import { pathToFileURL } from 'node:url'
22
22
  import { loadConfig, buildScriptInvocation, type PackageManager } from './lib/config'
23
23
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
24
+ import { isDurabilityRequired, verifyCommittedDesignEvidence } from './lib/evidence'
25
+ import { createEvidencePorts } from './lib/evidence-ports'
24
26
  import { parseStatusZ, STATUS_Z_ARGS } from './lib/porcelain'
25
27
  import { reviewScratchPaths } from './lib/scratch'
28
+ import { computeReviewSemanticIdentity } from './lib/review-target'
26
29
  import {
27
30
  loadState,
28
31
  readPhases,
@@ -127,13 +130,19 @@ export interface NextInput {
127
130
  packageManager: PackageManager
128
131
  /** 설계 문서 3종이 git 인덱스에 전부 있는가. */
129
132
  designDocsInIndex: boolean
130
- /** 현재 설계문서 바인딩 해시. 계산 불가면 null. */
133
+ /** 현재 설계문서 바인딩 해시(design **freshness** 판정용 — approval binding, compare_hash 아님). 계산 불가면 null. */
131
134
  currentDesignHash: string | null
132
135
  hasStagedChanges: boolean
133
136
  /** G1: `findUnstagedOrUntracked`가 비었는가(리뷰 가능한 워킹트리). */
134
137
  worktreeReviewClean: boolean
135
- /** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. */
138
+ /** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. (레거시 경로 호환용 — G2 compare는 아래 semantic identity를 쓴다.) */
136
139
  currentIndexHash: string | null
140
+ /**
141
+ * 🔴 REQ-2026-052: 현재 **semantic identity**(review-target.ts, `responses/` 제외). G2 compare_hash 재계산 정본.
142
+ * review-codex가 `last_review.compare_hash`에 semantic identity를 기록하므로, req:next도 같은 값으로 비교한다.
143
+ * pre-call 원장 커밋·evidence-finalize에 불변이라 방금 승인한 리뷰를 stale로 오판하지 않는다. 계산 불가면 null.
144
+ */
145
+ currentSemanticIdentity: string | null
137
146
  /** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
138
147
  reviewBudget: ReviewBudget
139
148
  /**
@@ -141,6 +150,14 @@ export interface NextInput {
141
150
  * 필수 필드다(선택 아님): 해소는 config 계층에서 끝나므로 resolveNext는 내부 기본값을 두지 않는다.
142
151
  */
143
152
  phaseCommitAutoApprove: PhaseCommitPolicy
153
+ /**
154
+ * REQ-2026-048 DEC-4: **커밋된** design 증거 검증 결과. `main()`이 `HEAD` blob으로 계산해 채운다.
155
+ *
156
+ * - `undefined` = 미계산(2-arg/legacy 호출) → 기존 DONE 동작 그대로.
157
+ * - `{ required:false }` = legacy 티켓(HEAD 스캐폴드에 marker 없음) → 기존 DONE 동작 그대로.
158
+ * - `{ required:true, durable:false }` = 신규 티켓인데 증거가 커밋되지 않음 → **DONE 대신 BLOCKED**.
159
+ */
160
+ designEvidenceDurability?: { required: boolean; durable: boolean; reason: string }
144
161
  }
145
162
 
146
163
  /** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
@@ -559,7 +576,7 @@ export function resolveNext(input: NextInput): NextAction {
559
576
  command: reviewCmd(pm, target, 'design', null),
560
577
  kind: 'design',
561
578
  phaseId: null,
562
- compareHash: input.currentDesignHash,
579
+ compareHash: input.currentSemanticIdentity,
563
580
  detail: state.design_approved === true ? '설계 문서가 승인 이후 변경됐다(stale). 재승인이 필요하다.' : '설계 승인이 필요하다.',
564
581
  })
565
582
 
@@ -595,11 +612,32 @@ export function resolveNext(input: NextInput): NextAction {
595
612
  command: reviewCmd(pm, target, 'phase', pending),
596
613
  kind: 'phase',
597
614
  phaseId: pending,
598
- compareHash: input.currentIndexHash,
615
+ compareHash: input.currentSemanticIdentity,
599
616
  detail: `phase \`${pending}\`의 staged 변경을 리뷰받는다.`,
600
617
  })
601
618
  }
602
619
 
620
+ // ── REQ-2026-048 DEC-4: 완료 선언 직전 **커밋된** design 증거 검증(신규 티켓 전용) ──
621
+ // 🔴 여기서만 fail-closed다. `req:doctor`·일반 `req:commit`에는 넣지 않는다 — doctor는 req:commit의
622
+ // 하드 게이트라 FAIL이면 기존 소비자의 모든 커밋이 벽돌이 된다. 완료 판정만 막으면 충분하다.
623
+ // 🔴 온디스크가 아니라 HEAD blob 기준이다(D17이 온디스크로 통과해 이 갭이 조용했다).
624
+ {
625
+ const dur = input.designEvidenceDurability
626
+ if (input.worktreeReviewClean && !input.hasStagedChanges && dur?.required === true && dur.durable !== true) {
627
+ const cmd = buildScriptInvocation(input.packageManager, 'req:commit', [
628
+ ...targetArgs(input.target),
629
+ '--finalize-design',
630
+ '--run',
631
+ ]).join(' ')
632
+ return {
633
+ kind: 'BLOCKED',
634
+ detail:
635
+ `모든 phase가 끝났지만 **커밋된** design 승인 증거가 완비되지 않았다: ${dur.reason}. ` +
636
+ `이 상태로 통합하면 fresh clone에 설계 승인 감사 증거가 남지 않는다. 복구: \`${cmd}\``,
637
+ }
638
+ }
639
+ }
640
+
603
641
  if (input.worktreeReviewClean && !input.hasStagedChanges) {
604
642
  // REQ-2026-037 R5: 자동 커밋(low-only)에선 매 phase 정지가 없으므로, 병합 전 **단일** 사람 확인을
605
643
  // 종단에서 실체화한다 — DONE(exit 11)이 아니라 AWAIT_HUMAN(exit 10)으로 루프를 확실히 멈춘다.
@@ -647,7 +685,7 @@ function resolveLegacy(input: NextInput, consumed: { phase_id: string | null }[]
647
685
  command: reviewCmd(pm, target, 'phase', null),
648
686
  kind: 'phase',
649
687
  phaseId: null,
650
- compareHash: input.currentIndexHash,
688
+ compareHash: input.currentSemanticIdentity,
651
689
  detail: '레거시 티켓(phase 미추적)의 staged 변경을 리뷰받는다.',
652
690
  })
653
691
 
@@ -750,8 +788,24 @@ export function main(argv: string[] = process.argv.slice(2)): void {
750
788
  hasStagedChanges: roGit(['diff', '--cached', '--name-only']).trim().length > 0,
751
789
  worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
752
790
  currentIndexHash: captureIndexHash(roGit),
791
+ // 🔴 REQ-2026-052: G2 compare_hash 재계산 정본(read-only ls-files, responses/ 제외). review-codex가
792
+ // last_review.compare_hash에 이 값을 기록하므로 같은 값으로 비교한다.
793
+ currentSemanticIdentity: (() => {
794
+ try {
795
+ return computeReviewSemanticIdentity(ticketRel, roGit)
796
+ } catch {
797
+ return null // ls-files 실패 등 → null(G2 통과·fail-forward, 게이트 강제는 review-codex에 있다).
798
+ }
799
+ })(),
753
800
  reviewBudget: cfg.reviewBudget,
754
801
  phaseCommitAutoApprove: cfg.phaseCommit.autoApprove,
802
+ // REQ-2026-048 DEC-4: marker와 증거 모두 **HEAD blob**에서 읽는다(워킹 캐시 신뢰 금지).
803
+ designEvidenceDurability: (() => {
804
+ const ports = createEvidencePorts(cfg.root, `${ticketRel}/responses`)
805
+ const required = isDurabilityRequired(ports.headText(`${ticketRel}/state.json`))
806
+ if (!required) return { required: false, durable: true, reason: 'legacy 티켓(marker 없음) — 점검 불요' }
807
+ return { required: true, ...verifyCommittedDesignEvidence({ ticketRel, ports }) }
808
+ })(),
755
809
  })
756
810
 
757
811
  if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:reconstruct — 소실된 close-proof lifecycle 행을 **HEAD-committed immutable evidence만으로** 복원한다
4
+ * (REQ-2026-052 phase-4·DEC-D2). 복원 가능성 매트릭스는 `lib/reconstruct`(순수)가 판정하고, 이 CLI는 evidence를
5
+ * HEAD blob에서 뽑아 넣고 부작용(durable commit)을 낸다.
6
+ *
7
+ * 🔴 **HEAD blob만** 읽는다 — runtime state·워킹트리·미커밋 원장·추정 phase 목록을 근거로 쓰지 않는다.
8
+ * 🔴 `verifyCommittedEvidenceIntegrity`가 실패한(손상) 티켓은 **복원하지 않고 fail-closed**한다.
9
+ * 🔴 **dev-complete·phase_design_ref·design/phase archive는 절대 합성하지 않는다.** state.json을 고치지 않는다.
10
+ * 🔴 기본 **dry-run**. `--run` + **`--confirm`(사람 확인)** 후에만 write. 새 행은 `reconstructed:true` +
11
+ * 비어있지 않은 `evidence_basis`. append-only·자연키 멱등 → 재시도가 중복 행/추가 커밋을 만들지 않는다.
12
+ *
13
+ * 사용: req:reconstruct <REQ> [--run] [--confirm] [--root <dir>]
14
+ */
15
+ import { writeFileSync } from 'node:fs'
16
+ import { join, relative } from 'node:path'
17
+ import { pathToFileURL } from 'node:url'
18
+ import { loadConfig, packageRoot } from './lib/config'
19
+ import { createGitAdapter, type GitAdapter } from './lib/adapters'
20
+ import { createEvidencePorts } from './lib/evidence-ports'
21
+ import { verifyCommittedEvidenceIntegrity } from './lib/evidence'
22
+ import { parseCloseProof, appendCloseProofRow, closeProofPath, type CloseProofRow } from './lib/close-proof'
23
+ import { planReconstruction, type SuccessorEvidence, type ReconstructPlan } from './lib/reconstruct'
24
+ import { listHeadTicketIds } from './lib/intake'
25
+ import { isValidHumanResolution } from './review-codex'
26
+
27
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
28
+ function git(args: string[]): string {
29
+ return gitAdapter.exec(args)
30
+ }
31
+ /** `HEAD:<repoRel>` blob 텍스트(없으면 null). */
32
+ function headBlob(repoRel: string): string | null {
33
+ try {
34
+ return git(['show', `HEAD:${repoRel}`])
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ export interface Opts {
41
+ reqId: string | null
42
+ run: boolean
43
+ confirm: boolean
44
+ root: string | null
45
+ }
46
+
47
+ /** 인자 파싱(fail-closed): 값 누락·알 수 없는 옵션은 즉시 throw. */
48
+ export function parseArgs(argv: string[]): Opts {
49
+ const o: Opts = { reqId: null, run: false, confirm: false, root: null }
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i]
52
+ if (a === undefined) continue
53
+ if (a === '--') continue
54
+ else if (a === '--run') o.run = true
55
+ else if (a === '--confirm') o.confirm = true
56
+ else if (a === '--root') {
57
+ const v = argv[++i]
58
+ if (v === undefined) throw new Error('--root 값 필요')
59
+ o.root = v
60
+ } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
61
+ else o.reqId = a
62
+ }
63
+ return o
64
+ }
65
+
66
+ /**
67
+ * 이 티켓을 replace 부모로 지목하는 committed successor 증거를 HEAD tree에서 수집(read-only).
68
+ * 🔴 구식 successor_of(`parent_series_id` 없음)·비-replace·형식 무효는 제외한다(복원 불가 — 수집 안 함).
69
+ */
70
+ export function collectSuccessorEvidence(workflowDirRel: string, parentReqId: string, gitFn: (a: string[]) => string): SuccessorEvidence[] {
71
+ const headBlobVia = (rel: string): string | null => {
72
+ try {
73
+ return gitFn(['show', `HEAD:${rel}`])
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+ const out: SuccessorEvidence[] = []
79
+ for (const id of listHeadTicketIds(workflowDirRel, gitFn)) {
80
+ if (id === parentReqId) continue
81
+ const rel = `${workflowDirRel}/${id}`
82
+ const stateText = headBlobVia(`${rel}/state.json`)
83
+ if (stateText === null) continue
84
+ let so: unknown
85
+ try {
86
+ so = (JSON.parse(stateText) as { successor_of?: unknown }).successor_of
87
+ } catch {
88
+ continue
89
+ }
90
+ if (!so || typeof so !== 'object') continue
91
+ const s = so as { req_id?: unknown; parent_series_id?: unknown; parent_replace_resolution?: unknown }
92
+ if (s.req_id !== parentReqId) continue
93
+ if (typeof s.parent_series_id !== 'string' || !s.parent_series_id) continue // 구식 → 복원 불가
94
+ const hr = s.parent_replace_resolution
95
+ if (!isValidHumanResolution(hr) || (hr as { decision?: unknown }).decision !== 'replace') continue
96
+ out.push({
97
+ successorTicketId: id,
98
+ successorStatePath: `${rel}/state.json`,
99
+ parentSeriesId: s.parent_series_id,
100
+ resolution: 'replace', // collect는 decision==='replace'만 통과시킨다(material field로 명시).
101
+ at: (hr as { decided_at: string }).decided_at,
102
+ })
103
+ }
104
+ return out
105
+ }
106
+
107
+ function renderPlan(reqId: string, plan: ReconstructPlan): string {
108
+ const lines: string[] = [`[req:reconstruct] ${reqId} — 복원 계획(HEAD-committed 증거 기준)`]
109
+ if (plan.candidates.length) {
110
+ lines.push(' 복원 예정 행:')
111
+ for (const c of plan.candidates)
112
+ lines.push(` - series-terminal series_id=${c.row.series_id} resolution=${c.row.resolution} at=${c.row.at} · evidence_basis=[${c.evidenceBasis.join(', ')}]`)
113
+ } else {
114
+ lines.push(' 복원 예정 행: 없음')
115
+ }
116
+ if (plan.refusals.length) {
117
+ lines.push(' 복원 불가·불필요·모호:')
118
+ for (const r of plan.refusals) lines.push(` - ${r}`)
119
+ }
120
+ if (plan.conflicts.length) {
121
+ lines.push(' 🔴 fail-closed conflict(HEAD 모순 — write 0):')
122
+ for (const c of plan.conflicts) lines.push(` - ${c}`)
123
+ }
124
+ return lines.join('\n')
125
+ }
126
+
127
+ export function main(argv: string[] = process.argv.slice(2)): void {
128
+ const o = parseArgs(argv)
129
+ if (!o.reqId) throw new Error('REQ 필요 (예: req:reconstruct 2026-029)')
130
+ const cfg = loadConfig({ root: o.root })
131
+ gitAdapter = createGitAdapter(cfg.root)
132
+ const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
133
+ const ticketDir = join(cfg.workflowDirAbs, reqId)
134
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
135
+ const workflowDirRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
136
+
137
+ // 🔴 1. 손상 티켓은 복원하지 않는다(fail-closed) — verifyCommittedEvidenceIntegrity(design+phase HEAD 무결성).
138
+ const ports = createEvidencePorts(cfg.root, `${ticketRel}/responses`)
139
+ const manifestText = ports.headText(`${ticketRel}/responses/approvals.jsonl`)
140
+ const integrity = verifyCommittedEvidenceIntegrity({ ticketRel, manifestText, ports })
141
+ if (integrity.problems.length)
142
+ throw new Error(`${reqId}: committed 증거 손상 — 복원 거부(fail-closed): ${integrity.problems.slice(0, 3).join('; ')}`)
143
+
144
+ // 2. 이 티켓 HEAD close-proof(모순/중복 판정 기준). 손상이면 거부.
145
+ const cpRel = closeProofPath(ticketRel)
146
+ const cpText = headBlob(cpRel)
147
+ const parsed = cpText ? parseCloseProof(cpText) : { rows: [], problems: [] }
148
+ if (parsed.problems.length)
149
+ throw new Error(`${reqId}: HEAD close-proof 손상 — 복원 거부: ${parsed.problems.slice(0, 3).join('; ')}`)
150
+
151
+ // 3. successor 증거 수집(HEAD tree) → 매트릭스(순수) 판정.
152
+ const successors = collectSuccessorEvidence(workflowDirRel, reqId, (a) => git(a))
153
+ const plan = planReconstruction({ ticketId: reqId, existingRows: parsed.rows, successors })
154
+ console.log(renderPlan(reqId, plan))
155
+
156
+ // 🔴 conflict(HEAD 모순)는 dry-run/`--run` 무관하게 **명령 전체를 fail-closed**한다 — 멱등으로 숨기지 않는다.
157
+ if (plan.conflicts.length)
158
+ throw new Error(`${reqId}: HEAD close-proof와 모순되는 복원 대상(conflict) — fail-closed(write 0): ${plan.conflicts.join('; ')}`)
159
+
160
+ // 4. 실행 모델.
161
+ if (!o.run) {
162
+ console.log('[req:reconstruct] DRY-RUN — write 없음(--run 시 실행).')
163
+ return
164
+ }
165
+ if (!o.confirm) {
166
+ console.log('[req:reconstruct] --run 지정됐으나 사람 확인 없음 — write 0. 위 계획을 확인했으면 `--confirm` 을 추가하세요.')
167
+ return
168
+ }
169
+ if (plan.candidates.length === 0) {
170
+ console.log('[req:reconstruct] 복원 가능한 행이 없습니다 — no-op(write 0).')
171
+ return
172
+ }
173
+
174
+ // 🔴 5. 쓰기 전 안전: 대상 close-proof가 **HEAD와 동일**(워킹트리·인덱스에 미커밋 변경 없음)이어야 한다.
175
+ // 아니면 HEAD 기반으로 덮어쓰다 사용자의 미커밋 close-proof 행을 **잃는다**(리뷰 P1). 불일치면 fail-closed.
176
+ const cpDirty = git(['status', '--porcelain', '--', cpRel]).trim()
177
+ if (cpDirty)
178
+ throw new Error(
179
+ `${reqId}: close-proof(${cpRel})에 미커밋 변경이 있어 복원을 거부합니다(fail-closed) — HEAD 기반 쓰기가 미커밋 행을 덮어쓸 수 있습니다.\n` +
180
+ ` 먼저 미커밋 close-proof 변경을 커밋하거나 정리한 뒤 다시 실행하세요.`,
181
+ )
182
+
183
+ // 6. 쓰기: append(자연키 멱등) → durable commit(pathspec). 재시도는 duplicate라 커밋 diff 없음.
184
+ let content = cpText ?? ''
185
+ let appended = 0
186
+ for (const c of plan.candidates) {
187
+ const res = appendCloseProofRow(content, c.row)
188
+ if (res.outcome === 'appended') {
189
+ content = res.content
190
+ appended++
191
+ } else if (res.outcome === 'conflict') {
192
+ throw new Error(`복원 충돌(fail-closed): ${res.problems.join('; ')}`)
193
+ }
194
+ // duplicate → 이미 존재(멱등 skip).
195
+ }
196
+ if (appended === 0) {
197
+ console.log('[req:reconstruct] 대상 행이 이미 모두 존재 — no-op(멱등).')
198
+ return
199
+ }
200
+ writeFileSync(join(cfg.root, cpRel), content, 'utf8')
201
+ git(['add', '--', cpRel])
202
+ git(['commit', '-m', `chore(${reqId}): reconstruct series-terminal close proof (evidence-based)`, '--', cpRel])
203
+ console.log(`[req:reconstruct] ✅ ${appended}행 복원·durable commit(reconstructed:true·evidence_basis).`)
204
+ }
205
+
206
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). */
207
+ export function runCli(argv: string[]): void {
208
+ try {
209
+ main(argv)
210
+ } catch (err) {
211
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
212
+ process.exitCode = 1
213
+ }
214
+ }
215
+
216
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
217
+ if (isMain) main()