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 +20 -0
- package/bin/init.ts +6 -1
- package/bin/uninstall.ts +67 -4
- package/package.json +76 -76
- package/scripts/req/lib/adapters.ts +14 -0
- package/scripts/req/lib/evidence-ports.ts +8 -1
- package/scripts/req/lib/review-target.ts +16 -5
- package/scripts/req/lib/state-checkpoint.ts +78 -0
- package/scripts/req/req-commit.ts +44 -4
- package/scripts/req/req-next.ts +21 -2
- package/scripts/req/req-reconstruct.ts +11 -4
- package/scripts/req/review-codex.ts +23 -2
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
|
-
|
|
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
|
-
|
|
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(` 빈 디렉터리(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
43
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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)
|
package/scripts/req/req-next.ts
CHANGED
|
@@ -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',
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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'),
|
|
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)}
|