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/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
- if (existsSync(destAbs) && !force) {
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 경로들이 **실제로 만들어질 있는지** preflight에서 확인한다 (D8).
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
- /** 계획대로 복사(중첩 디렉터리 생성). `--dry-run`이면 아무것도 쓰지 않는다. */
681
- function applyCopies(targetRoot: string, plan: InstallPlan): void {
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
- if (!existsSync(pkgPath))
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
- const existingCfg = existsSync(cfgPath) ? parseJsonObject(cfgPath, 'req.config.json') : null
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
- const agentsCreated = !existsSync(agentsPath)
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
- const claudeMdCreated = !agentEntrypointsSkipped && !existsSync(claudeMdPath)
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
- const agentsContractCopyCreated = agentsMarkerMissing && (!existsSync(contractCopyPath) || opts.force)
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
- // 상위 컴포넌트를 lstat으로 검사해 내부·외부·dangling 링크를 모두 거부한다(P1).
935
- // preflight라 스키마 복사(같은 workflow/ 경유)보다 먼저 돌아 함께 보호된다.
936
- assertConfinedDest(targetRoot, KIT_GITIGNORE.dest)
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 기존 kit 파일 덮어쓰기(기본: 스킵)
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.7.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
- "AGENTS.template.md",
51
- "req.config.json.sample",
52
- "README.md",
53
- "README.en.md",
54
- "CHANGELOG.md"
55
- ],
56
- "engines": {
57
- "node": ">=18.17"
58
- },
59
- "dependencies": {
60
- "ajv": "^8.20.0",
61
- "cross-spawn": "^7.0.6",
62
- "semver": "^7.6.3",
63
- "tsx": "^4.19.1"
64
- },
65
- "devDependencies": {
66
- "@types/cross-spawn": "^6.0.6",
67
- "@types/node": "^22.7.4",
68
- "@types/semver": "^7.5.8",
69
- "typescript": "^5.6.2",
70
- "vitest": "^2.1.2"
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
+ }
@@ -8,6 +8,7 @@
8
8
  "granularityMaxFiles": 8,
9
9
  "reviewModel": "gpt-5.6-terra",
10
10
  "reviewReasoningEffort": "high",
11
+ "reviewBudget": { "autoBudget": 5, "hardCap": 8 },
11
12
  "designDocs": {
12
13
  "requirement": "00-requirement.md",
13
14
  "design": "01-design.md",
@@ -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
- const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/ // new Date().toISOString() 형식
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 (typeof e.approved_at !== 'string' || !ISO_RE.test(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
167
- if (typeof e.consumed_at !== 'string' || !ISO_RE.test(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
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 (typeof c.confirmed_at !== 'string' || !ISO_RE.test(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
232
+ if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
232
233
  return null
233
234
  }
234
235