commitgate 0.7.0 → 0.8.1
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 +45 -0
- package/README.en.md +87 -4
- package/README.md +82 -4
- package/bin/init.ts +199 -28
- package/bin/uninstall.ts +5 -0
- package/package.json +73 -72
- package/req.config.json.sample +1 -0
- package/scripts/req/lib/config.ts +30 -0
- package/scripts/req/req-commit.ts +5 -4
- package/scripts/req/req-new.ts +35 -6
- package/scripts/req/req-next.ts +61 -0
- package/scripts/req/review-codex.ts +658 -31
- package/skills/ATTRIBUTION.md +85 -0
- package/skills/commitgate-diagnosing-bugs/SKILL.md +149 -0
- package/skills/commitgate-discovery/SKILL.md +93 -0
- package/skills/commitgate-research/SKILL.md +85 -0
- package/skills/commitgate-tdd/SKILL.md +113 -0
- package/workflow/machine.schema.json +5 -0
- package/workflow/req.config.schema.json +88 -13
- package/workflow/review-persona.md +34 -0
package/bin/init.ts
CHANGED
|
@@ -95,6 +95,23 @@ export const KIT_CLAUDE_DEST_REL = 'CLAUDE.md'
|
|
|
95
95
|
*/
|
|
96
96
|
export const KIT_GITIGNORE = { src: 'templates/workflow.gitignore', dest: 'workflow/.gitignore' } as const
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* companion skills (REQ-2026-020). Builder용 방법론 instruction asset — 실행 코드가 아니다.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ `CONTRACT_POINTER_RELPATHS`에 **섞지 않는다**(설계 D6). 그 상수는 "계약 포인터"라는 의미를 갖고,
|
|
102
|
+
* companion skills는 **없어도 핵심 워크플로가 동일하게 동작**한다(R10). 의미가 다르므로 별도 목록이다.
|
|
103
|
+
* gitignore WARN/`--strict` 동작은 같은 축에서 받되, 기존 계약 포인터 경고를 약화시키지 않는다.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ `add()`를 타지 않는다(설계 D3). `add()`의 skip은 `existsSync && !force`라 **`--force`가 사용자 수정을 덮는다**.
|
|
106
|
+
* 스킬은 사용자가 고치라고 만든 자산이므로 `workflow/.gitignore`(D12)와 같은 seed-once 축으로 간다.
|
|
107
|
+
*/
|
|
108
|
+
export const KIT_COMPANION_SKILLS = [
|
|
109
|
+
{ src: 'skills/commitgate-discovery/SKILL.md', dest: '.claude/skills/commitgate-discovery/SKILL.md' },
|
|
110
|
+
{ src: 'skills/commitgate-tdd/SKILL.md', dest: '.claude/skills/commitgate-tdd/SKILL.md' },
|
|
111
|
+
{ src: 'skills/commitgate-diagnosing-bugs/SKILL.md', dest: '.claude/skills/commitgate-diagnosing-bugs/SKILL.md' },
|
|
112
|
+
{ src: 'skills/commitgate-research/SKILL.md', dest: '.claude/skills/commitgate-research/SKILL.md' },
|
|
113
|
+
] as const
|
|
114
|
+
|
|
98
115
|
/** `AGENTS.md`가 CommitGate 계약인지 판별하는 마커. 진입점 포인터들이 이 마커로 SSOT를 확인한다. */
|
|
99
116
|
export const AGENTS_CONTRACT_MARKER = '<!-- commitgate:contract -->'
|
|
100
117
|
|
|
@@ -398,6 +415,86 @@ function assertConfinedDest(targetRoot: string, destRel: string): void {
|
|
|
398
415
|
}
|
|
399
416
|
}
|
|
400
417
|
|
|
418
|
+
/**
|
|
419
|
+
* 쓰기 dest의 **confinement + leaf 상태를 한 번에** 판정한다 (REQ-2026-024 D1·D2).
|
|
420
|
+
*
|
|
421
|
+
* @returns 일반 파일이면 그 `Stats`, **실제 부재(ENOENT)면 `null`**. 그 밖은 전부 throw(fail-closed).
|
|
422
|
+
*
|
|
423
|
+
* 🔴 **`existsSync`를 부재 판정에 쓰면 안 된다.** 실측(REQ-2026-024 §2)으로 확인된 4가지 탈출:
|
|
424
|
+
* - **A. dangling leaf**: `existsSync`가 **false** → "부재" 오판 → 쓰기가 링크를 따라 대상 **밖에 생성**.
|
|
425
|
+
* - **B. ancestor dir symlink**: `existsSync`·`statSync`가 링크를 **따라가** 통과 → `mkdirSync`+쓰기가 밖에.
|
|
426
|
+
* - **C. live leaf + `--force`**: skip 조건이 `existsSync && !force`라 force면 `copyFileSync`가 **외부를 덮어씀**.
|
|
427
|
+
* - **D. live leaf + `writeFileSync`**: 기존 파일로 읽고 병합 → 쓰기가 링크 따라 **외부를 수정**.
|
|
428
|
+
* A만 막으면 C·D가 남는다 — 그쪽은 생성이 아니라 **기존 외부 파일 파괴**라 더 무겁다.
|
|
429
|
+
*
|
|
430
|
+
* 🔴 **반환값이 곧 부재 판정이다**(D2). 호출부가 `existsSync`를 쓸 이유를 없앤다 —
|
|
431
|
+
* 검사를 빼먹으려면 판정도 포기해야 하므로 **드리프트가 구조적으로 불가능**하다.
|
|
432
|
+
* "쓰기 전에 검사를 호출한다"는 규율은 이미 실패했다: `workflow/.gitignore`·companion에는 붙고
|
|
433
|
+
* 나머지 7종에는 안 붙은 것이 이번 결함이다.
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ 루트 직속 dest(`AGENTS.md`·`package.json` 등)에서 `assertConfinedDest`는 **무동작**이다
|
|
436
|
+
* (`segs.length - 1 === 0`). 정상이다 — 그 상위는 `targetRoot` 자신이고 `runInit`이 이미 검사한다.
|
|
437
|
+
* 그 경우 leaf `lstat`이 방어를 맡는다.
|
|
438
|
+
*
|
|
439
|
+
* ⚠️ **TOCTOU는 막지 않는다.** 이 판정과 실제 쓰기 사이에 경로가 바뀌는 경쟁은 남는다
|
|
440
|
+
* (Node에 `O_NOFOLLOW` 원자 API가 없다). **협조적 사용자의 우발적 symlink**를 막는 것이다.
|
|
441
|
+
*/
|
|
442
|
+
function statWritableDest(targetRoot: string, destRel: string): ReturnType<typeof lstatSync> | null {
|
|
443
|
+
assertConfinedDest(targetRoot, destRel) // 상위 컴포넌트 전부 lstat (leaf는 아래에서)
|
|
444
|
+
const abs = join(targetRoot, destRel)
|
|
445
|
+
let st: ReturnType<typeof lstatSync>
|
|
446
|
+
try {
|
|
447
|
+
st = lstatSync(abs)
|
|
448
|
+
} catch (e) {
|
|
449
|
+
// ⚠️ **ENOENT만** 부재로 인정한다 — EACCES·ELOOP를 부재로 삼키면 apply에서 늦게 실패해 부분 설치가 된다(롤백 0줄).
|
|
450
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT')
|
|
451
|
+
throw new Error(`${destRel} 상태 확인 실패(${(e as Error).message}) — fail-closed.`)
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
if (!st.isFile())
|
|
455
|
+
throw new Error(`${destRel} 가 일반 파일이 아닙니다(symlink·디렉터리·특수파일) — 그 경로를 옮기고 재시도하십시오.`)
|
|
456
|
+
return st
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** companion skills 계획 결과(순수 판정 — 쓰기 없음). */
|
|
460
|
+
export interface CompanionSkillsPlan {
|
|
461
|
+
/** 부재(lstat ENOENT)라 새로 만들 것. */
|
|
462
|
+
create: { srcAbs: string; destRel: string }[]
|
|
463
|
+
/** 이미 있고 **바이트 동일** = 직전 실행이 깐 것 → 설치 커밋 stage 목록에 편입. */
|
|
464
|
+
ownedSkips: string[]
|
|
465
|
+
/** 이미 있고 다름 = 사용자가 고친 것 → 보존(`--force`로도 안 덮음). */
|
|
466
|
+
userDiffers: string[]
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* companion skills seed-once 판정 + confinement preflight (설계 D3·D4).
|
|
471
|
+
* **순수 판정이라 아무것도 쓰지 않는다** — `--dry-run`도 이 검사를 그대로 받는다(쓰기 0건, 그러나 symlink면 실패).
|
|
472
|
+
*
|
|
473
|
+
* `workflow/.gitignore`(D12)와 같은 축이고, 다른 점은 **dest가 4개**라는 것뿐이다:
|
|
474
|
+
*
|
|
475
|
+
* 🔴 **각 최종 `SKILL.md` dest를 개별로 넘긴다.** skills 루트(`.claude/skills`)만 넘기면
|
|
476
|
+
* `assertConfinedDest`의 `i < segs.length - 1` 루프가 **마지막 컴포넌트를 검사하지 않아**
|
|
477
|
+
* `commitgate-<name>`이 외부 symlink여도 통과하고, `copyFileSync`가 대상 **밖에** 쓴다.
|
|
478
|
+
*
|
|
479
|
+
* confinement + leaf 판정은 `statWritableDest`가 한다 — 이 함수의 본문이 그 헬퍼의 출처다
|
|
480
|
+
* (REQ-2026-024 D1: 같은 규칙이 두 벌 있었고, 나머지 7종에 안 붙은 것이 결함이었다).
|
|
481
|
+
*/
|
|
482
|
+
export function planCompanionSkills(targetRoot: string): CompanionSkillsPlan {
|
|
483
|
+
const create: { srcAbs: string; destRel: string }[] = []
|
|
484
|
+
const ownedSkips: string[] = []
|
|
485
|
+
const userDiffers: string[] = []
|
|
486
|
+
for (const { src, dest } of KIT_COMPANION_SKILLS) {
|
|
487
|
+
// 상위 전부(.claude → .claude/skills → .claude/skills/commitgate-<name>) + leaf를 lstat으로 검사.
|
|
488
|
+
const st = statWritableDest(targetRoot, dest)
|
|
489
|
+
const destAbs = join(targetRoot, dest)
|
|
490
|
+
const srcAbs = join(PACKAGE_ROOT, src)
|
|
491
|
+
if (st === null) create.push({ srcAbs, destRel: dest })
|
|
492
|
+
else if (sha256File(destAbs) === sha256File(srcAbs)) ownedSkips.push(dest)
|
|
493
|
+
else userDiffers.push(dest) // 사용자가 고친 것 — `--force`도 보지 않는다(D3).
|
|
494
|
+
}
|
|
495
|
+
return { create, ownedSkips, userDiffers }
|
|
496
|
+
}
|
|
497
|
+
|
|
401
498
|
/**
|
|
402
499
|
* `<pm> install`이 만드는 `node_modules/`가 **워킹트리를 dirty하게 만드는가**.
|
|
403
500
|
* ignore되지도 tracked되지도 않으면 `?? node_modules/`로 나타나 `req:new --run`의 clean-tree 게이트를 막는다.
|
|
@@ -572,6 +669,11 @@ export interface PlanFacts {
|
|
|
572
669
|
workflowGitignoreWillCreate: boolean
|
|
573
670
|
/** 기존 `workflow/.gitignore`가 템플릿과 바이트 동일(= 직전 실행이 깐 것). ownedSkips에 직접 편입(D4 축). */
|
|
574
671
|
workflowGitignoreOwnedSkip: boolean
|
|
672
|
+
/**
|
|
673
|
+
* companion skills 계획(REQ-2026-020). `add()`를 타지 않으므로(D3 seed-once) 여기로 받아
|
|
674
|
+
* copies/ownedSkips에 **직접** 편입한다 — 그러지 않으면 stageList에서 빠져 unrelated로 오분류된다.
|
|
675
|
+
*/
|
|
676
|
+
companionSkills: CompanionSkillsPlan
|
|
575
677
|
}
|
|
576
678
|
|
|
577
679
|
/**
|
|
@@ -614,7 +716,11 @@ export function planInstall(targetRoot: string, force: boolean, pm: PackageManag
|
|
|
614
716
|
}
|
|
615
717
|
const add = (srcAbs: string, destRel: string): void => {
|
|
616
718
|
const destAbs = join(targetRoot, destRel)
|
|
617
|
-
|
|
719
|
+
// 🔴 confinement + leaf 판정(REQ-2026-024 D2). **반환값이 곧 부재 판정이다** — `existsSync`를 쓰면
|
|
720
|
+
// dangling leaf(A)를 부재로 오판하고, ancestor dir symlink(B)를 통과시키며, `--force`가 링크를 따라
|
|
721
|
+
// 대상 **밖** 사용자 파일을 덮어쓴다(C, 실측 E8). 검사와 판정이 같은 호출이라 빼먹을 수 없다.
|
|
722
|
+
const st = statWritableDest(targetRoot, destRel)
|
|
723
|
+
if (st !== null && !force) {
|
|
618
724
|
skips.push(destRel)
|
|
619
725
|
// 바이트가 같으면 CommitGate 소유(직전 실행이 깐 것). 다르면 사용자 파일 — 설치 커밋에 담지 않는다.
|
|
620
726
|
const a = sha(destAbs)
|
|
@@ -634,6 +740,11 @@ export function planInstall(targetRoot: string, force: boolean, pm: PackageManag
|
|
|
634
740
|
// workflow/.gitignore는 add()를 타지 않는다(D12: --force가 사용자 파일을 덮으면 안 됨). 바이트 동일 재실행분은
|
|
635
741
|
// 여기서 ownedSkips에 **직접** 편입 — 그러지 않으면 stageList에서 빠져 unrelated로 오분류된다(phase-2 리뷰 R1).
|
|
636
742
|
if (facts.workflowGitignoreOwnedSkip) ownedSkips.push(KIT_GITIGNORE.dest)
|
|
743
|
+
// companion skills도 add()를 타지 않는다(D3). 생성분은 copies로, 바이트 동일분은 ownedSkips로 직접 편입 —
|
|
744
|
+
// 그래야 artifacts·stageList·ignore 검사가 이들을 함께 본다. userDiffers는 사용자 파일이라 설치 커밋에 담지 않는다.
|
|
745
|
+
for (const c of facts.companionSkills.create) copies.push(c)
|
|
746
|
+
for (const d of facts.companionSkills.ownedSkips) ownedSkips.push(d)
|
|
747
|
+
for (const d of facts.companionSkills.userDiffers) skips.push(d)
|
|
637
748
|
|
|
638
749
|
return {
|
|
639
750
|
copies,
|
|
@@ -653,10 +764,20 @@ export function planInstall(targetRoot: string, force: boolean, pm: PackageManag
|
|
|
653
764
|
}
|
|
654
765
|
|
|
655
766
|
/**
|
|
656
|
-
* 진입점 dest
|
|
767
|
+
* 진입점 dest의 **ENOTDIR 조기 진단**(D8) — 더 나은 에러 메시지가 목적이다.
|
|
657
768
|
*
|
|
658
769
|
* `mkdirSync(recursive)`는 경로 중간 컴포넌트가 **파일**이면 ENOTDIR로 죽는다. apply 단계에서 그러면
|
|
659
|
-
* 앞의 파일들은 이미 복사된 뒤라 **부분 설치**가 된다. 쓰기 전에 걸러서 preflight→apply 계약을
|
|
770
|
+
* 앞의 파일들은 이미 복사된 뒤라 **부분 설치**가 된다. 쓰기 전에 걸러서 preflight→apply 계약을 지키고,
|
|
771
|
+
* `--no-agent-entrypoints`라는 출구를 안내한다.
|
|
772
|
+
*
|
|
773
|
+
* 🔴 **이 함수는 confinement 방어가 아니다**(REQ-2026-024 D4). `existsSync`·`statSync`는 **링크를 따라간다** —
|
|
774
|
+
* 상위가 외부를 가리키는 symlink여도 `isDirectory()`가 true라 그대로 통과한다(실측: v0.7.0에서 `.cursor`
|
|
775
|
+
* junction이 여기를 통과해 대상 밖에 파일을 만들었다).
|
|
776
|
+
* **방어는 `statWritableDest`가 한다** — 이 함수 뒤의 `add()`·개별 dest 판정이 그것을 호출한다.
|
|
777
|
+
* 여기를 lstat 기반으로 바꾸지 **않는다**: 같은 규칙이 두 벌이 되고, 그 이중화가 이번 결함의 발생 방식이다.
|
|
778
|
+
*
|
|
779
|
+
* ⚠️ 순서 의존: symlink dest에 대해 이 함수가 먼저 **다른 메시지**로 throw할 수 있다(예: 상위가 파일 symlink면
|
|
780
|
+
* "디렉터리가 아니라 파일입니다"). 보안상 무해하다 — 둘 다 쓰기 전 throw이고 메시지도 틀리지 않았다.
|
|
660
781
|
*/
|
|
661
782
|
function assertEntrypointPathsUsable(targetRoot: string): void {
|
|
662
783
|
const dests = [...KIT_AGENT_ENTRYPOINTS.map((e) => e.dest), KIT_CLAUDE_DEST_REL, KIT_AGENTS_CONTRACT_COPY_REL]
|
|
@@ -677,8 +798,23 @@ function assertEntrypointPathsUsable(targetRoot: string): void {
|
|
|
677
798
|
}
|
|
678
799
|
}
|
|
679
800
|
|
|
680
|
-
/**
|
|
681
|
-
|
|
801
|
+
/**
|
|
802
|
+
* 계획대로 복사(중첩 디렉터리 생성). 호출부가 `--dry-run`이면 이 함수를 아예 부르지 않는다.
|
|
803
|
+
*
|
|
804
|
+
* 🔴 **쓰기 전 전량 검증**(REQ-2026-024 D3). `plan.copies`는 `add()`만 채우지 않는다 —
|
|
805
|
+
* `planCompanionSkills`의 결과가 **직접 편입**된다(REQ-2026-020 D3). 즉 `add()`의 preflight를
|
|
806
|
+
* **우회하는 경로가 이미 존재한다.** 여기서 불변식을 강제해 미래의 우회도 잡는다.
|
|
807
|
+
*
|
|
808
|
+
* 🔴 **두 루프여야 한다.** 검사·쓰기를 한 루프에 섞으면 중간 throw 시 앞의 파일이 이미 복사돼
|
|
809
|
+
* **부분 설치**가 된다(롤백 0줄) — preflight→apply 계약 위반이다.
|
|
810
|
+
*
|
|
811
|
+
* ⚠️ 이 검사는 **불변식 강제**지 주 방어선이 아니다. 주 방어선은 preflight(`add()`·`planCompanionSkills`)다.
|
|
812
|
+
* preflight가 완전하면 여기는 **절대 발화하지 않는다** — 그래서 `runInit` 경유로는 검증할 수 없고
|
|
813
|
+
* (preflight가 먼저 터진다) 이 함수를 **직접 호출**하는 테스트만이 공허하지 않다. export 이유가 그것이다.
|
|
814
|
+
* TOCTOU도 막지 못한다 — 검사와 `copyFileSync` 사이에도 창이 있다.
|
|
815
|
+
*/
|
|
816
|
+
export function applyCopies(targetRoot: string, plan: InstallPlan): void {
|
|
817
|
+
for (const { destRel } of plan.copies) statWritableDest(targetRoot, destRel)
|
|
682
818
|
for (const { srcAbs, destRel } of plan.copies) {
|
|
683
819
|
const destAbs = join(targetRoot, destRel)
|
|
684
820
|
mkdirSync(dirname(destAbs), { recursive: true })
|
|
@@ -815,7 +951,10 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
815
951
|
assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
|
|
816
952
|
|
|
817
953
|
const pkgPath = join(targetRoot, 'package.json')
|
|
818
|
-
|
|
954
|
+
// 🔴 confinement를 **읽기보다 앞**에 둔다(REQ-2026-024 D5). symlink면 `writeFileSync`가 링크를 따라
|
|
955
|
+
// 대상 **밖** package.json을 수정한다(실측 E6). 검사를 읽기 뒤에 두면 외부 파일을 읽고 나서 막는 셈이다.
|
|
956
|
+
// 부재(null)는 기존 메시지 그대로 — 동작 변경은 symlink/특수파일 케이스에 한정된다.
|
|
957
|
+
if (statWritableDest(targetRoot, 'package.json') === null)
|
|
819
958
|
throw new Error(`package.json 없음: ${targetRoot} — 'npm init' 등으로 먼저 생성(req:* 스크립트 주입 대상).`)
|
|
820
959
|
const pkg = parseJsonObject(pkgPath, 'package.json') as {
|
|
821
960
|
scripts?: Record<string, string>
|
|
@@ -860,7 +999,9 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
860
999
|
}
|
|
861
1000
|
|
|
862
1001
|
const cfgPath = join(targetRoot, 'req.config.json')
|
|
863
|
-
|
|
1002
|
+
// 🔴 confinement를 **읽기보다 앞**에(D5). dangling이면 `writeFileSync`가 대상 밖에 **생성**하고(E4),
|
|
1003
|
+
// live symlink면 외부 파일을 읽어 병합한 뒤 링크를 따라 **수정**한다(E5). 아래 loadConfig도 이 파일을 읽는다.
|
|
1004
|
+
const existingCfg = statWritableDest(targetRoot, 'req.config.json') !== null ? parseJsonObject(cfgPath, 'req.config.json') : null
|
|
864
1005
|
// 기존 config는 워크플로 CONFIG_SCHEMA(additionalProperties·enum·type) + 경로 confinement까지 preflight 검증(phase R2 P2).
|
|
865
1006
|
// kit의 loadConfig를 재사용 — schema-invalid(unknown key·bad enum·escaping ticketRoot 등)면 복사 전 throw(첫 req:* 지연 실패 방지).
|
|
866
1007
|
// 병합은 유효 키만 추가(handoffPath:null·packageManager)라 "기존 유효 ⇒ 병합 유효".
|
|
@@ -909,7 +1050,9 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
909
1050
|
// `REQ_DEV_DEPS` 상수는 남는다 — `bin/uninstall.ts`가 기존 Stage A 설치본의 devDeps를 분류하는 데 쓴다.
|
|
910
1051
|
|
|
911
1052
|
const agentsPath = join(targetRoot, 'AGENTS.md')
|
|
912
|
-
|
|
1053
|
+
// 🔴 반환값이 곧 부재 판정이다(D2). `!existsSync`면 dangling을 부재로 오판해 `copyFileSync`가
|
|
1054
|
+
// 링크를 따라 대상 밖에 AGENTS.md를 만든다(실측 E1). 아래 마커 읽기(readFileSync)도 이 검사 뒤에 온다.
|
|
1055
|
+
const agentsCreated = statWritableDest(targetRoot, 'AGENTS.md') === null
|
|
913
1056
|
|
|
914
1057
|
const agentEntrypointsSkipped = opts.noAgentEntrypoints === true
|
|
915
1058
|
if (!agentEntrypointsSkipped) assertEntrypointPathsUsable(targetRoot)
|
|
@@ -920,33 +1063,25 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
920
1063
|
!agentEntrypointsSkipped && !agentsCreated && !readFileSync(agentsPath, 'utf8').includes(AGENTS_CONTRACT_MARKER)
|
|
921
1064
|
|
|
922
1065
|
const claudeMdPath = join(targetRoot, KIT_CLAUDE_DEST_REL)
|
|
923
|
-
|
|
1066
|
+
// `--no-agent-entrypoints`면 이 dest를 쓰지 않으므로 검사도 돌리지 않는다(D5/D7 의미 유지 — 단락 평가).
|
|
1067
|
+
const claudeMdCreated = !agentEntrypointsSkipped && statWritableDest(targetRoot, KIT_CLAUDE_DEST_REL) === null
|
|
924
1068
|
|
|
925
1069
|
// 마커가 없으면 포인터가 참조할 계약 템플릿을 **대상 repo에** 놓는다 — 그러지 않으면 복구 지시가 막다른 길이다.
|
|
926
1070
|
const contractCopyPath = join(targetRoot, KIT_AGENTS_CONTRACT_COPY_REL)
|
|
927
|
-
|
|
1071
|
+
// `agentsMarkerMissing`가 거짓이면 이 dest를 쓰지 않으므로 검사도 돌리지 않는다(단락 평가).
|
|
1072
|
+
// ⚠️ `|| opts.force`가 남아 있다 — force면 **존재해도 덮어쓴다**. 그래서 symlink 거부가 특히 중요하다:
|
|
1073
|
+
// 검사가 없으면 force가 링크를 따라 대상 밖 파일을 덮어쓴다(모드 C, `add()`의 E8과 같은 축).
|
|
1074
|
+
const agentsContractCopyCreated =
|
|
1075
|
+
agentsMarkerMissing && (statWritableDest(targetRoot, KIT_AGENTS_CONTRACT_COPY_REL) === null || opts.force)
|
|
928
1076
|
|
|
929
1077
|
// workflow/.gitignore(kit 파일, REQ-2026-012). AGENTS.md 정책: **부재 시에만** 생성, --force로도 안 덮음(D12).
|
|
930
1078
|
// --no-agent-entrypoints와 무관(D13).
|
|
931
1079
|
const workflowGitignorePath = join(targetRoot, KIT_GITIGNORE.dest)
|
|
932
1080
|
const workflowGitignoreSrcAbs = join(PACKAGE_ROOT, KIT_GITIGNORE.src)
|
|
933
|
-
// confinement: workflow/(또는 그 상위)가 symlink면 copyFileSync가 밖에 쓰거나 git이 경로를 stage하지
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
// fail-closed: 존재하면 반드시 **일반 파일**이어야 한다(P1). lstat로 symlink를 안 따라간다 —
|
|
938
|
-
// symlink면 copyFileSync가 링크 대상을 따라 쓰고 git도 링크된 .gitignore를 규칙으로 안 읽는다. 디렉터리·특수파일도 복사 불가.
|
|
939
|
-
// ⚠️ **ENOENT만** 부재로 인정한다 — EACCES·ELOOP 등을 부재로 삼키면 apply로 넘어가 늦게 실패한다.
|
|
940
|
-
let wgLstat: ReturnType<typeof lstatSync> | null = null
|
|
941
|
-
try {
|
|
942
|
-
wgLstat = lstatSync(workflowGitignorePath)
|
|
943
|
-
} catch (e) {
|
|
944
|
-
if ((e as NodeJS.ErrnoException).code !== 'ENOENT')
|
|
945
|
-
throw new Error(`${KIT_GITIGNORE.dest} 상태 확인 실패(${(e as Error).message}) — fail-closed.`)
|
|
946
|
-
wgLstat = null
|
|
947
|
-
}
|
|
948
|
-
if (wgLstat !== null && !wgLstat.isFile())
|
|
949
|
-
throw new Error(`${KIT_GITIGNORE.dest} 가 일반 파일이 아닙니다(symlink·디렉터리·특수파일) — 그 경로를 옮기고 재시도하십시오.`)
|
|
1081
|
+
// confinement + leaf: workflow/(또는 그 상위)가 symlink면 copyFileSync가 밖에 쓰거나 git이 경로를 stage하지 못하고,
|
|
1082
|
+
// leaf가 symlink면 링크 대상을 따라 쓰며 git도 링크된 .gitignore를 규칙으로 안 읽는다. 디렉터리·특수파일도 복사 불가.
|
|
1083
|
+
// preflight라 스키마 복사(같은 workflow/ 경유)보다 먼저 돌아 함께 보호된다.
|
|
1084
|
+
const wgLstat = statWritableDest(targetRoot, KIT_GITIGNORE.dest)
|
|
950
1085
|
const workflowGitignoreExists = wgLstat !== null // isFile 보장
|
|
951
1086
|
const workflowGitignoreCreated = !workflowGitignoreExists
|
|
952
1087
|
// 존재 & 바이트 동일 = 직전 실행이 깐 것(소유). ⚠️ add()를 타지 않으므로(D12) ownedSkips를 **직접** 채운다 —
|
|
@@ -963,6 +1098,13 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
963
1098
|
gitIsIgnored(targetRoot, KIT_GITIGNORE.dest) &&
|
|
964
1099
|
!gitIsTracked(targetRoot, KIT_GITIGNORE.dest)
|
|
965
1100
|
|
|
1101
|
+
// companion skills(REQ-2026-020 D3·D4). **preflight**라 `--dry-run`도 이 검사를 받는다 —
|
|
1102
|
+
// dry-run이 조용히 통과하면 실설치 직전에야 터진다. 판정은 순수(쓰기 0건)다.
|
|
1103
|
+
// `.claude/` 계층이므로 `--no-agent-entrypoints`면 통째로 건너뛴다(D5/D7 의미 유지).
|
|
1104
|
+
const companionSkills: CompanionSkillsPlan = agentEntrypointsSkipped
|
|
1105
|
+
? { create: [], ownedSkips: [], userDiffers: [] }
|
|
1106
|
+
: planCompanionSkills(targetRoot)
|
|
1107
|
+
|
|
966
1108
|
// 설치 **전** 워킹트리(쓰기 전 스냅샷). `.gitignore`의 dirty 여부 판정에도 쓰이므로 계획보다 먼저 찍는다.
|
|
967
1109
|
const porcelain = gitStatusEntries(targetRoot)
|
|
968
1110
|
const nmWillDirty = nodeModulesWillDirty(targetRoot)
|
|
@@ -978,6 +1120,7 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
978
1120
|
gitignoreWillJoin: gitignoreJoinsInstall(nmWillDirty, porcelain),
|
|
979
1121
|
workflowGitignoreWillCreate: workflowGitignoreCreated,
|
|
980
1122
|
workflowGitignoreOwnedSkip,
|
|
1123
|
+
companionSkills,
|
|
981
1124
|
})
|
|
982
1125
|
const artifacts = planArtifactPaths(plan)
|
|
983
1126
|
|
|
@@ -1018,6 +1161,32 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
1018
1161
|
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
1019
1162
|
console.warn(`⚠️ ${msg}\n (설치는 계속 — 안전한 커밋 안내는 생략됩니다)`)
|
|
1020
1163
|
}
|
|
1164
|
+
// companion skills가 ignored∧untracked면 설치 커밋에 못 담겨 팀원 fresh clone에 전달되지 않는다(REQ-2026-021 D1).
|
|
1165
|
+
//
|
|
1166
|
+
// 🔴 **`artifacts`로 판정하면 안 된다.** `userDiffers`는 `skips`로 가서 `planArtifactPaths`
|
|
1167
|
+
// (= `copies + ownedSkips + extras`)에 **없다** → `findIgnoredArtifacts`가 그 파일을 보지 못한다.
|
|
1168
|
+
// 그러면 나머지가 전부 추적된 상태에서 사용자가 skill 하나만 고쳤을 때 **경고 없이 `--strict`가 통과**한다.
|
|
1169
|
+
// 계획 3분류(create·ownedSkips·userDiffers)를 **전부** 본다 — 소유든 사용자 파일이든, 팀에 전달되지
|
|
1170
|
+
// 못하면 안전한 설치 안내를 낼 수 없다. `workflowGitignorePolicyAtRisk`와 같은 축이다(D1).
|
|
1171
|
+
//
|
|
1172
|
+
// ⚠️ `CONTRACT_POINTER_RELPATHS`에 섞지 않는다(REQ-2026-020 D6) — companion은 계약 포인터가 아니다.
|
|
1173
|
+
// 같은 WARN/strict **동작**만 별도 판정으로 준다. 기존 포인터 경고는 손대지 않는다(추가만).
|
|
1174
|
+
const companionAtRisk = [
|
|
1175
|
+
...companionSkills.create.map((c) => c.destRel),
|
|
1176
|
+
...companionSkills.ownedSkips,
|
|
1177
|
+
...companionSkills.userDiffers,
|
|
1178
|
+
].filter((p) => gitIsIgnored(targetRoot, p) && !gitIsTracked(targetRoot, p))
|
|
1179
|
+
|
|
1180
|
+
if (companionAtRisk.length > 0) {
|
|
1181
|
+
const msg =
|
|
1182
|
+
`companion skills가 무시되고 있어 설치 커밋에 담을 수 없습니다:\n` +
|
|
1183
|
+
companionAtRisk.map((p) => ` ${p}`).join('\n') +
|
|
1184
|
+
`\n 팀원의 fresh clone·CI에는 이 스킬들이 없습니다(그쪽 Builder는 방법론 보조를 못 받습니다).\n` +
|
|
1185
|
+
` 추적하려면: git add -f <위 경로> 또는 .gitignore 에서 해당 규칙을 걷어내십시오.`
|
|
1186
|
+
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
1187
|
+
console.warn(`⚠️ ${msg}\n (설치는 계속 — 강제 중단하려면 --strict)`)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1021
1190
|
// staged·overlapping이 있으면 **안전한 커밋 안내를 만들 수 없다**(커밋이 인덱스 전체를 담고, 겹침은 사후 분리 불가).
|
|
1022
1191
|
// 기본 모드는 설치를 막지 않는다(비파괴·비-breaking) — 안내를 내지 않을 뿐. `--strict`는 쓰기 전에 중단한다.
|
|
1023
1192
|
if (opts.strict && (preexistingDirty.staged.length > 0 || preexistingDirty.overlapping.length > 0)) {
|
|
@@ -1267,7 +1436,9 @@ function printHelp(): void {
|
|
|
1267
1436
|
|
|
1268
1437
|
옵션:
|
|
1269
1438
|
--dir <path> 대상 repo 루트(기본: 현재 디렉터리)
|
|
1270
|
-
--force
|
|
1439
|
+
--force 덮어쓰기 가능한 kit 항목만 갱신(기본: 스킵).
|
|
1440
|
+
AGENTS.md · CLAUDE.md · workflow/.gitignore · companion skills(.claude/skills/commitgate-*)는
|
|
1441
|
+
기존 파일을 **보존**합니다 — --force 로도 덮어쓰지 않습니다.
|
|
1271
1442
|
--dry-run 변경 없이 수행 예정 목록만 출력
|
|
1272
1443
|
--strict 정합성 경고를 설치 실패로 취급(fail-closed)
|
|
1273
1444
|
--no-agent-entrypoints
|
package/bin/uninstall.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
KIT_COPY_RELPATHS,
|
|
31
31
|
KIT_AGENT_ENTRYPOINTS,
|
|
32
32
|
KIT_GITIGNORE,
|
|
33
|
+
KIT_COMPANION_SKILLS,
|
|
33
34
|
KIT_CLAUDE_TEMPLATE_REL,
|
|
34
35
|
KIT_CLAUDE_DEST_REL,
|
|
35
36
|
KIT_AGENTS_CONTRACT_COPY_REL,
|
|
@@ -230,6 +231,10 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
230
231
|
{ destRel: KIT_AGENTS_CONTRACT_COPY_REL, srcRel: 'AGENTS.template.md' },
|
|
231
232
|
// workflow/.gitignore(REQ-2026-012): src≠dest. identical이면 제거 대상, differs면 review(사용자 편집 보존). 부재 정상.
|
|
232
233
|
{ destRel: KIT_GITIGNORE.dest, srcRel: KIT_GITIGNORE.src },
|
|
234
|
+
// companion skills(REQ-2026-021 D5): src≠dest. `KIT_COMPANION_SKILLS`가 이미 `{src, dest}` shape라 직접 매핑한다.
|
|
235
|
+
// `--no-agent-entrypoints`·미설치면 부재가 **정상**이므로 present=false여도 문제 없다(entrypoint와 같은 성질).
|
|
236
|
+
// ⚠️ 분류는 기존 규칙을 그대로 탄다 — 새 예외를 만들지 않는다. `differs`는 "편집됐거나 **다른 버전**이 설치함"이다.
|
|
237
|
+
...KIT_COMPANION_SKILLS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
|
|
233
238
|
]
|
|
234
239
|
const tool: ToolArtifact[] = toolEntries.map(({ destRel, srcRel }) => {
|
|
235
240
|
const dest = join(targetRoot, destRel)
|
package/package.json
CHANGED
|
@@ -1,72 +1,73 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "commitgate",
|
|
3
|
-
"version": "0.
|
|
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
|
-
},
|
|
42
|
-
"files": [
|
|
43
|
-
"scripts/req",
|
|
44
|
-
"scripts/verify-review-overrides.mjs",
|
|
45
|
-
"workflow/machine.schema.json",
|
|
46
|
-
"workflow/req.config.schema.json",
|
|
47
|
-
"workflow/review-persona.md",
|
|
48
|
-
"bin",
|
|
49
|
-
"templates",
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"README.
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"@types/
|
|
68
|
-
"@types/
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "commitgate",
|
|
3
|
+
"version": "0.8.1",
|
|
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
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"scripts/req",
|
|
44
|
+
"scripts/verify-review-overrides.mjs",
|
|
45
|
+
"workflow/machine.schema.json",
|
|
46
|
+
"workflow/req.config.schema.json",
|
|
47
|
+
"workflow/review-persona.md",
|
|
48
|
+
"bin",
|
|
49
|
+
"templates",
|
|
50
|
+
"skills",
|
|
51
|
+
"AGENTS.template.md",
|
|
52
|
+
"req.config.json.sample",
|
|
53
|
+
"README.md",
|
|
54
|
+
"README.en.md",
|
|
55
|
+
"CHANGELOG.md"
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18.17"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"ajv": "^8.20.0",
|
|
62
|
+
"cross-spawn": "^7.0.6",
|
|
63
|
+
"semver": "^7.6.3",
|
|
64
|
+
"tsx": "^4.19.1"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/cross-spawn": "^6.0.6",
|
|
68
|
+
"@types/node": "^22.7.4",
|
|
69
|
+
"@types/semver": "^7.5.8",
|
|
70
|
+
"typescript": "^5.6.2",
|
|
71
|
+
"vitest": "^2.1.2"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/req.config.json.sample
CHANGED
|
@@ -27,6 +27,12 @@ export interface DesignDocs {
|
|
|
27
27
|
*/
|
|
28
28
|
export type ReviewReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
|
29
29
|
|
|
30
|
+
/** review 예산(REQ-2026-028 A-2a). config↔review-codex 순환 방지를 위해 여기(config)에 정의. */
|
|
31
|
+
export interface ReviewBudget {
|
|
32
|
+
autoBudget: number
|
|
33
|
+
hardCap: number
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
/** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
|
|
31
37
|
export interface RawConfig {
|
|
32
38
|
ticketRoot?: string
|
|
@@ -42,6 +48,8 @@ export interface RawConfig {
|
|
|
42
48
|
reviewModel?: string | null
|
|
43
49
|
/** codex 리뷰 추론강도(REQ-2026-013 P1). null = `-c model_reasoning_effort=` 생략. 미지정 = DEFAULTS. */
|
|
44
50
|
reviewReasoningEffort?: ReviewReasoningEffort | null
|
|
51
|
+
/** REQ-2026-028 A-2a: review 예산. 미지정 = DEFAULTS(5/8). hardCap≤8·autoBudget≤hardCap은 loadConfig 검증. */
|
|
52
|
+
reviewBudget?: ReviewBudget
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
|
|
@@ -57,6 +65,7 @@ export interface ResolvedConfig {
|
|
|
57
65
|
designDocs: DesignDocs
|
|
58
66
|
reviewModel: string | null
|
|
59
67
|
reviewReasoningEffort: ReviewReasoningEffort | null
|
|
68
|
+
reviewBudget: ReviewBudget
|
|
60
69
|
// 파생(절대경로)
|
|
61
70
|
workflowDirAbs: string
|
|
62
71
|
schemaPathAbs: string
|
|
@@ -104,6 +113,8 @@ export const DEFAULTS = {
|
|
|
104
113
|
// `as ... | null`은 handoffPath와 같은 이유(직접 import 소비자의 `| null` 계약 보존).
|
|
105
114
|
reviewModel: 'gpt-5.6-terra' as string | null,
|
|
106
115
|
reviewReasoningEffort: 'high' as ReviewReasoningEffort | null,
|
|
116
|
+
// REQ-2026-028 A-2a: review 예산. autoBudget=자동 허용 회차, hardCap=절대 상한(9번째 차단 → 8).
|
|
117
|
+
reviewBudget: { autoBudget: 5, hardCap: 8 } as ReviewBudget,
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
|
|
@@ -125,6 +136,16 @@ export const CONFIG_SCHEMA = {
|
|
|
125
136
|
reviewModel: { type: ['string', 'null'], pattern: BASENAME_RE },
|
|
126
137
|
// effort는 실측 확정 enum(R15) + null. null을 enum에 포함해야 `{effort:null}`이 통과(JSON Schema enum은 타입 무관 전체 적용).
|
|
127
138
|
reviewReasoningEffort: { type: ['string', 'null'], enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', null] },
|
|
139
|
+
// REQ-2026-028 A-2a: 예산. 스키마는 타입·상한(hardCap≤8·최소 1)까지. 교차검증(autoBudget≤hardCap)은 loadConfig.
|
|
140
|
+
reviewBudget: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
required: ['autoBudget', 'hardCap'],
|
|
144
|
+
properties: {
|
|
145
|
+
autoBudget: { type: 'integer', minimum: 1 },
|
|
146
|
+
hardCap: { type: 'integer', minimum: 1, maximum: 8 },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
128
149
|
designDocs: {
|
|
129
150
|
type: 'object',
|
|
130
151
|
additionalProperties: false,
|
|
@@ -209,8 +230,17 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
|
|
|
209
230
|
reviewModel: raw.reviewModel !== undefined ? raw.reviewModel : DEFAULTS.reviewModel,
|
|
210
231
|
reviewReasoningEffort:
|
|
211
232
|
raw.reviewReasoningEffort !== undefined ? raw.reviewReasoningEffort : DEFAULTS.reviewReasoningEffort,
|
|
233
|
+
reviewBudget: raw.reviewBudget ?? DEFAULTS.reviewBudget,
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
// REQ-2026-028 R7: 교차검증(스키마가 표현 못 함). AJV가 이미 hardCap∈[1,8]·autoBudget≥1을 잡았고,
|
|
237
|
+
// 여기서 autoBudget ≤ hardCap을 강제(fail-closed). R4("9번째는 어떤 경로로도 차단")는 설정을 넘는 코드
|
|
238
|
+
// 상수 경계다 — hardCap>8은 스키마가 거부하므로 config 한 줄로 뚫을 수 없다.
|
|
239
|
+
if (merged.reviewBudget.autoBudget > merged.reviewBudget.hardCap)
|
|
240
|
+
throw new Error(
|
|
241
|
+
`req.config: reviewBudget.autoBudget(${merged.reviewBudget.autoBudget}) > hardCap(${merged.reviewBudget.hardCap}) — autoBudget는 hardCap 이하여야 한다`,
|
|
242
|
+
)
|
|
243
|
+
|
|
214
244
|
// repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
|
|
215
245
|
// handoffPath만 면제 — 형제 repo의 SSOT 문서를 읽는 **외부 참조**이기 때문.
|
|
216
246
|
// reviewPersonaPath는 패키지가 배포하고 init이 repo 안에 까는 자원이라 schemaPath와 같은 축이다(REQ-2026-010 D2).
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
readPhases,
|
|
21
21
|
archiveBaseName,
|
|
22
22
|
isArchiveFileName,
|
|
23
|
+
isValidIsoInstant,
|
|
23
24
|
type ApprovalEvidence,
|
|
24
25
|
type ReviewKind,
|
|
25
26
|
type WorkflowState,
|
|
@@ -34,7 +35,7 @@ let pkgManager: PackageManager = DEFAULTS.packageManager
|
|
|
34
35
|
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
35
36
|
const SHA256_RE = /^[0-9a-f]{64}$/i
|
|
36
37
|
const GIT_OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i // git OID: 40(SHA-1) 또는 64(SHA-256)
|
|
37
|
-
|
|
38
|
+
// ISO 검증은 review-codex의 isValidIsoInstant(형식+달력 유효성) 재사용 — 달력 불가능 값 거부(REQ-2026-030).
|
|
38
39
|
// evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
|
|
39
40
|
const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
|
|
40
41
|
const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
|
|
@@ -163,8 +164,8 @@ export function validateManifest(
|
|
|
163
164
|
if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
|
|
164
165
|
if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
|
|
165
166
|
if (typeof e.consumed_by_commit_sha !== 'string' || !GIT_OID_RE.test(e.consumed_by_commit_sha)) problems.push(`line ${ln}: consumed_by_commit_sha 비-OID`)
|
|
166
|
-
if (
|
|
167
|
-
if (
|
|
167
|
+
if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
|
|
168
|
+
if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
|
|
168
169
|
// kind별 strict 바인딩(반대 kind 필드 금지).
|
|
169
170
|
if (kind === 'phase') {
|
|
170
171
|
if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
|
|
@@ -228,7 +229,7 @@ export function userConfirmProblem(ucc: unknown): string | null {
|
|
|
228
229
|
const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
|
|
229
230
|
if (c.confirmed !== true) return 'confirmed=true 아님'
|
|
230
231
|
if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
|
|
231
|
-
if (
|
|
232
|
+
if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
|
|
232
233
|
return null
|
|
233
234
|
}
|
|
234
235
|
|