commitgate 0.1.0
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/AGENTS.template.md +48 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/commitgate.mjs +21 -0
- package/bin/init.ts +328 -0
- package/package.json +62 -0
- package/req.config.json.sample +13 -0
- package/scripts/req/lib/adapters.ts +139 -0
- package/scripts/req/lib/config.ts +177 -0
- package/scripts/req/req-commit.ts +792 -0
- package/scripts/req/req-doctor.ts +526 -0
- package/scripts/req/req-new.ts +173 -0
- package/scripts/req/review-codex.ts +927 -0
- package/workflow/machine.schema.json +38 -0
- package/workflow/req.config.schema.json +21 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# AGENTS.md (AI REQ workflow — Codex/Builder 계약)
|
|
2
|
+
|
|
3
|
+
> 이 파일은 `commitgate`(init)가 대상 repo에 `AGENTS.md`가 없을 때 생성한 **템플릿**이다.
|
|
4
|
+
> 프로젝트 고유 규칙(SSOT 경로·포트·참조 프로젝트·phase 문서 등)은 여기에 직접 채워 넣어라.
|
|
5
|
+
> Codex CLI는 repo 루트의 `AGENTS.md`를 리뷰 컨텍스트로 읽는다.
|
|
6
|
+
|
|
7
|
+
## 역할
|
|
8
|
+
|
|
9
|
+
- **Builder(Claude 등)**: REQ 티켓 단위로 설계→구현→테스트. `req:new`로 티켓·브랜치 생성.
|
|
10
|
+
- **Reviewer(Codex)**: `req:review-codex`가 조립한 프롬프트로 design/phase 리뷰. 구조화 응답(`workflow/machine.schema.json`)만 승인 근거.
|
|
11
|
+
|
|
12
|
+
## 절대 규칙 (어기면 리뷰에서 reject)
|
|
13
|
+
|
|
14
|
+
### 1. Test-First
|
|
15
|
+
각 phase에서 게이트를 통과시키는 테스트를 먼저 작성(Red) → 최소 구현(Green) → 리팩토링. 테스트 없는 구현은 reject.
|
|
16
|
+
|
|
17
|
+
### 2. 게이트 통과 전 다음 phase 금지
|
|
18
|
+
`req:doctor` PASS + Codex phase 리뷰 승인(STEP_COMPLETE) 전에는 다음 phase로 진입하지 않는다.
|
|
19
|
+
|
|
20
|
+
### 3. 승인 바인딩(fail-closed) 우회 금지
|
|
21
|
+
- staged tree는 승인된 tree와 일치해야 한다(D9). 승인 후 재stage/설계 변경은 stale 승인으로 거부.
|
|
22
|
+
- 워킹트리는 리뷰 시점에 clean해야 한다(비-스크래치 unstaged/untracked → D10 FAIL).
|
|
23
|
+
- codex 미설치/실패는 silent 처리 금지 — 명확히 fail-closed.
|
|
24
|
+
|
|
25
|
+
### 4. 커밋 정책
|
|
26
|
+
- 각 phase는 의미 있는 커밋. "WIP" 금지.
|
|
27
|
+
- 커밋 메시지 컨벤션: `test/feat/fix/refactor/docs/chore` 접두사. `[Codex]`/`[Claude]` 같은 메타 정보 금지(Reviewer 편향 방지).
|
|
28
|
+
- HIGH 영향 phase는 `req:commit --run` 직전 사용자 확인(`state.user_commit_confirmed`).
|
|
29
|
+
|
|
30
|
+
## 사람에게 보고해야 할 때
|
|
31
|
+
|
|
32
|
+
- HIGH commit 실행 직전
|
|
33
|
+
- main merge / push 직전
|
|
34
|
+
- destructive 작업(reset/clean/force push) 필요
|
|
35
|
+
- 설계 범위 변경 또는 비목표 추가 필요
|
|
36
|
+
- Codex 리뷰 3라운드 이상 반복 또는 판단 불명확
|
|
37
|
+
- git·Codex CLI·Node·패키지매니저 등 필수 전제 미충족(fail-closed)
|
|
38
|
+
|
|
39
|
+
## 워크플로 명령
|
|
40
|
+
|
|
41
|
+
| 명령 | 용도 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `req:new <slug> --run` | REQ 티켓 생성(채번·브랜치·`00/01/02` 스캐폴드) |
|
|
44
|
+
| `req:review-codex <id> --kind design\|phase [--phase <p>] --run` | Codex 리뷰 조립·호출·승인 반영 |
|
|
45
|
+
| `req:doctor <id>` | 일관성 게이트(D-체크) |
|
|
46
|
+
| `req:commit <id> [--run] [--message-file <f>]` | 승인된 phase 커밋 + evidence-finalize |
|
|
47
|
+
|
|
48
|
+
설정은 repo 루트의 `req.config.json`(선택). 자세한 계약은 `README.md` 참조.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sol5288
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# CommitGate 🚦
|
|
2
|
+
|
|
3
|
+
**AI가 짠 코드를, 다른 AI가 리뷰하고 승인해야만 커밋되게 하는 "커밋 관문(gate)".**
|
|
4
|
+
|
|
5
|
+
> 한 줄 요약: **만드는 AI(Builder)** 와 **검사하는 AI(Reviewer)** 를 짝지어, **리뷰·승인·증거 없이는 커밋이 통과하지 못하게** 막아줍니다.
|
|
6
|
+
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🤔 이게 뭔가요? (비유로)
|
|
12
|
+
|
|
13
|
+
공항 보안 검색대를 떠올려 보세요. 아무리 급해도 **검색대를 통과하지 않으면** 탑승구로 갈 수 없죠.
|
|
14
|
+
|
|
15
|
+
CommitGate도 똑같습니다. 코드를 아무리 많이 만들어도 **"검사 → 승인"이라는 관문을 통과하지 않으면 `git commit`이 막힙니다.**
|
|
16
|
+
|
|
17
|
+
- 🛠️ **Builder(만드는 쪽)** — 당신, 또는 당신의 AI 코딩 도구(예: Claude Code). 요구사항을 설계하고 코드를 짭니다.
|
|
18
|
+
- 🔎 **Reviewer(검사하는 쪽)** — **Codex CLI**. Builder가 만든 걸 **독립적으로 다시 검토**해서 통과/보완을 판정합니다.
|
|
19
|
+
- 🚦 **CommitGate** — 그 사이에 서서, **Reviewer가 승인한 코드만** 커밋되게 하는 규칙(관문).
|
|
20
|
+
|
|
21
|
+
즉, "혼자 만들고 혼자 커밋"이 아니라 **"만든 AI ↔ 검사한 AI"의 교차 검증**을 강제합니다.
|
|
22
|
+
|
|
23
|
+
## 🎯 어떤 문제를 푸나요?
|
|
24
|
+
|
|
25
|
+
AI는 코드를 **빠르게** 만들지만, 검증 없이 그대로 커밋되면 위험합니다. CommitGate는:
|
|
26
|
+
|
|
27
|
+
- ✅ 모든 변경이 **리뷰를 반드시 거치게** 합니다.
|
|
28
|
+
- ✅ 승인은 **"리뷰한 바로 그 코드"에 묶입니다.** (승인 후 몰래 코드를 바꿔치기 못 함 — 지문처럼 대조)
|
|
29
|
+
- ✅ 무언가 애매하거나 빠지면 **일단 막습니다(fail-closed).** "확실할 때만 통과"가 기본값이라 안전합니다.
|
|
30
|
+
- ✅ 누가 언제 무엇을 승인했는지 **증거가 파일로 남습니다.**
|
|
31
|
+
|
|
32
|
+
## 🔄 동작 흐름 한눈에
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
① 티켓 만들기 → ② 설계 작성 → ③ 설계 리뷰(Codex) → ④ 코드 구현
|
|
36
|
+
req:new req:review-codex
|
|
37
|
+
|
|
38
|
+
→ ⑤ 관문 점검 → ⑥ 구현 리뷰(Codex) → ⑦ 승인되면 커밋
|
|
39
|
+
req:doctor req:review-codex req:commit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
각 단계에서 **통과하지 못하면 다음으로 못 넘어갑니다.**
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 📦 준비물 (Prerequisites)
|
|
47
|
+
|
|
48
|
+
시작 전에 아래 4가지가 필요합니다. 터미널에서 하나씩 확인해 보세요.
|
|
49
|
+
|
|
50
|
+
| 필요한 것 | 확인 명령 | 없으면 |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| **Git** (필수) | `git --version` | [git-scm.com](https://git-scm.com) 에서 설치 |
|
|
53
|
+
| **Node.js 18.17+** (필수) | `node --version` | [nodejs.org](https://nodejs.org) 에서 설치 |
|
|
54
|
+
| **Codex CLI** (리뷰용) | `codex --version` | OpenAI Codex CLI 설치 (없으면 리뷰만 안 되고 나머지는 됨) |
|
|
55
|
+
| **패키지 매니저** | `npm --version` | Node 설치 시 `npm`은 기본 포함 (또는 `pnpm`/`yarn`) |
|
|
56
|
+
|
|
57
|
+
> 💡 **Reviewer = Codex CLI** 입니다. 리뷰를 실제로 돌리려면 Codex CLI가 설치되어 있어야 해요. 없으면 리뷰 명령이 "안전하게 실패(fail-closed)"합니다 — 조용히 통과되는 일은 없습니다.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 🚀 설치 (딱 한 줄)
|
|
62
|
+
|
|
63
|
+
**당신의 프로젝트 폴더**(= git 저장소, `package.json` 있는 곳)에서:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npx commitgate
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
이 명령이 자동으로 해주는 일 (기존 파일은 **덮어쓰지 않아요**):
|
|
70
|
+
|
|
71
|
+
1. 워크플로 스크립트(`scripts/req/`)와 스키마를 복사합니다.
|
|
72
|
+
2. `req.config.json`(설정 파일)을 만들어 둡니다.
|
|
73
|
+
3. `package.json`에 `req:*` 명령들과 필요한 devDependencies(`tsx`, `ajv`)를 추가합니다.
|
|
74
|
+
4. `AGENTS.md`(Reviewer가 읽는 규칙 파일)가 없으면 템플릿을 만들어 줍니다.
|
|
75
|
+
|
|
76
|
+
설치 후, 방금 추가된 의존성을 받습니다:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm install
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> `--dry-run` 을 붙이면 **실제로 바꾸지 않고** 무엇을 할지 미리 볼 수 있어요: `npx commitgate --dry-run`
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 👣 처음부터 끝까지 따라하기
|
|
87
|
+
|
|
88
|
+
작은 기능 하나를 예로, 전체 흐름을 그대로 밟아 봅시다. (명령은 `npm` 기준이며, `npm run` 뒤 인자는 `--` 다음에 씁니다. `pnpm`을 쓰면 `pnpm req:new my-feature --run` 처럼 `--` 없이 됩니다.)
|
|
89
|
+
|
|
90
|
+
### 1단계 — 작업 티켓 만들기
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npm run req:new -- my-feature --run
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- 새 브랜치(`feat/req-...`)가 생기고, `workflow/REQ-2026-001/` 폴더에 설계 문서 3종이 만들어집니다.
|
|
97
|
+
- 출력에 **티켓 번호**(예: `REQ-2026-001`)가 나옵니다. 이후 명령에선 번호만 씁니다 → `2026-001`
|
|
98
|
+
|
|
99
|
+
### 2단계 — 설계 문서 작성
|
|
100
|
+
|
|
101
|
+
`workflow/REQ-2026-001/` 안의 세 파일을 채웁니다:
|
|
102
|
+
|
|
103
|
+
- `00-requirement.md` — **무엇을** 왜 만드는가
|
|
104
|
+
- `01-design.md` — **어떻게** 만들 것인가
|
|
105
|
+
- `02-plan.md` — 어떤 **순서/단계**로 진행할 것인가
|
|
106
|
+
|
|
107
|
+
### 3단계 — 설계 리뷰 받기 (Codex)
|
|
108
|
+
|
|
109
|
+
작성한 설계를 검사대에 올립니다(= `git add`):
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
git add workflow/REQ-2026-001/00-requirement.md workflow/REQ-2026-001/01-design.md workflow/REQ-2026-001/02-plan.md
|
|
113
|
+
npm run req:review-codex -- 2026-001 --kind design --run
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- Codex가 설계를 읽고 **승인** 또는 **보완 요청(NEEDS_FIX)** 을 돌려줍니다.
|
|
117
|
+
- 보완 요청이면 지적사항을 반영하고 **다시 `git add` → 위 명령을 재실행**하세요. 승인될 때까지 반복합니다.
|
|
118
|
+
|
|
119
|
+
### 4단계 — 코드 구현 + 테스트
|
|
120
|
+
|
|
121
|
+
설계가 승인되면 실제 코드를 짜고 테스트를 작성합니다.
|
|
122
|
+
|
|
123
|
+
### 5단계 — 관문 점검
|
|
124
|
+
|
|
125
|
+
내 변경을 검사대에 올리고, 게이트 상태를 미리 확인합니다:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
git add <내가-바꾼-코드-파일들>
|
|
129
|
+
npm run req:doctor -- 2026-001
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- 여러 항목(설계 승인 유효한지, 검사대에 올린 코드와 승인된 코드가 같은지, 워킹트리가 깨끗한지 등)을 점검해 **PASS/FAIL** 을 보여줍니다.
|
|
133
|
+
|
|
134
|
+
> ⚠️ **중요:** `git add` 는 **당신이 만든 코드/문서만** 올리세요. `state.json` 이나 `responses/` 같은 워크플로 내부 파일은 **올리지 마세요.** (도구가 알아서 관리합니다. 실수로 올리면 커밋 단계에서 막힙니다.)
|
|
135
|
+
|
|
136
|
+
### 6단계 — 구현 리뷰 받기 (Codex)
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
npm run req:review-codex -- 2026-001 --kind phase --run
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- Codex가 **구현 코드**를 리뷰합니다. 역시 승인될 때까지 반영 → 재실행 반복.
|
|
143
|
+
- 승인되면 `commit_allowed=true` 가 되어 커밋이 열립니다.
|
|
144
|
+
|
|
145
|
+
### 7단계 — 커밋
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
npm run req:commit -- 2026-001 --run -m "feat: my-feature 구현"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- 관문(doctor 게이트)을 마지막으로 통과한 뒤 **코드 커밋 + 증거 커밋** 을 남깁니다.
|
|
152
|
+
- 승인되지 않았거나, 승인 후 코드가 바뀌었으면 **여기서 막힙니다.** (그게 CommitGate의 핵심)
|
|
153
|
+
|
|
154
|
+
🎉 끝! 이제 리뷰·승인·증거가 모두 남은 상태로 안전하게 커밋되었습니다.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 📋 명령어 치트시트
|
|
159
|
+
|
|
160
|
+
| 명령 | 하는 일 |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `npx commitgate` | 프로젝트에 CommitGate 설치(스캐폴딩) |
|
|
163
|
+
| `req:new <이름> --run` | 새 작업 티켓 + 브랜치 + 설계문서 생성 |
|
|
164
|
+
| `req:review-codex <번호> --kind design --run` | **설계** 리뷰(Codex) |
|
|
165
|
+
| `req:review-codex <번호> --kind phase --run` | **구현** 리뷰(Codex) |
|
|
166
|
+
| `req:doctor <번호>` | 관문 점검(통과/실패 표시) |
|
|
167
|
+
| `req:commit <번호> --run -m "메시지"` | 승인된 코드 커밋(+증거) |
|
|
168
|
+
|
|
169
|
+
> `-m "메시지"` 대신 여러 줄 메시지는 `--message-file 메시지.txt` 로 파일에서 읽을 수 있습니다.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## ⚙️ 설정 (`req.config.json`, 선택)
|
|
174
|
+
|
|
175
|
+
프로젝트 루트의 `req.config.json` 으로 동작을 바꿀 수 있습니다. **파일이 없으면 기본값**으로 잘 동작하니, 처음엔 신경 쓰지 않아도 됩니다.
|
|
176
|
+
|
|
177
|
+
| 항목 | 기본값 | 뜻 |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `branchPrefix` | `"feat/req-"` | 새로 만드는 브랜치 이름 앞부분 |
|
|
180
|
+
| `ticketRoot` | `"workflow"` | 티켓 폴더 위치 |
|
|
181
|
+
| `packageManager` | 자동 감지 | `npm` / `pnpm` / `yarn` |
|
|
182
|
+
| `designDocs` | `00-/01-/02-*.md` | 설계문서 3종 파일명 |
|
|
183
|
+
|
|
184
|
+
잘못된 값(예: 빈 `branchPrefix`, 폴더 밖으로 벗어나는 경로)은 **안전하게 거부**됩니다.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## ❓ 자주 묻는 질문 / 문제 해결
|
|
189
|
+
|
|
190
|
+
**Q. `codex` 명령이 없다고 나와요.**
|
|
191
|
+
A. Reviewer(Codex CLI)가 설치되어 있어야 리뷰가 돌아갑니다. 설치 전에는 리뷰 명령이 "조용히 통과"하지 않고 **명확히 실패**합니다 — 이게 정상 동작(fail-closed)입니다.
|
|
192
|
+
|
|
193
|
+
**Q. 커밋 단계에서 "staged tree != approved" 같은 오류가 나요.**
|
|
194
|
+
A. **승인받은 코드와, 지금 검사대(git add)에 올린 코드가 다르다**는 뜻입니다. 승인 후에 코드를 바꿨다면 6단계(구현 리뷰)를 다시 받아 재승인하세요. (승인 바꿔치기를 막는 안전장치입니다.)
|
|
195
|
+
|
|
196
|
+
**Q. "비-코드 staged 금지(state/responses)" 오류가 나요.**
|
|
197
|
+
A. `git add` 로 `state.json` 이나 `responses/` 를 올렸기 때문입니다. 그 파일들은 **올리지 말고**(도구가 관리), 내가 만든 코드/문서만 올리세요.
|
|
198
|
+
|
|
199
|
+
**Q. `npx commitgate` 를 두 번 실행하면 덮어써지나요?**
|
|
200
|
+
A. 아니요. **이미 있는 파일은 건너뜁니다**(멱등). 강제로 덮어쓰려면 `--force` 를 붙이세요.
|
|
201
|
+
|
|
202
|
+
**Q. Windows인데 커밋 메시지 줄바꿈이 이상해요.**
|
|
203
|
+
A. 여러 줄 메시지는 `-m` 대신 `--message-file 파일.txt` 를 쓰면 깔끔합니다.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## 🔒 어떻게 "안전"을 보장하나요? (fail-closed)
|
|
208
|
+
|
|
209
|
+
CommitGate의 원칙은 **"확실히 승인됐을 때만 통과, 조금이라도 애매하면 막는다"** 입니다.
|
|
210
|
+
|
|
211
|
+
- 설계문서가 없거나 형식이 안 맞으면 → 미승인 취급
|
|
212
|
+
- Codex가 설치 안 됨 / 리뷰 실패 → 통과 아님, 명확히 실패
|
|
213
|
+
- 승인된 코드 지문과 지금 코드가 다르면 → 커밋 거부
|
|
214
|
+
- 워킹트리가 지저분하면(리뷰 안 한 변경 섞임) → 리뷰/커밋 거부
|
|
215
|
+
|
|
216
|
+
즉, **"막히는 게 기본, 통과가 예외"** 라 실수로 미검증 코드가 새어 나갈 틈을 줄입니다.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 📄 라이선스
|
|
221
|
+
|
|
222
|
+
[MIT](./LICENSE) © 2026 sol5288
|
|
223
|
+
|
|
224
|
+
> 이 워크플로는 `palm-kiosk-app`의 REQ-2026-017 portability kit에서 독립 패키지로 추출되었고, **자기 자신을 이 워크플로로 리뷰·승인(dogfood)** 하여 검증되었습니다.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* commitgate bin 런처(Stage A).
|
|
4
|
+
* npm bin은 TS를 직접 실행 못 하므로 tsx ESM 로더를 얹어 init.ts를 실행한다.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ tsx는 반드시 **이 런처(=패키지) 기준**으로 해소해야 한다(호출 cwd 아님).
|
|
7
|
+
* npx로 실행하면 cwd=대상 repo인데 그곳엔 아직 tsx가 없을 수 있어,
|
|
8
|
+
* bare `--import tsx`/cwd-상대 해소는 init 전에 ERR_MODULE_NOT_FOUND로 실패한다(REQ-2026-001 design R1 P1).
|
|
9
|
+
* `import ... from 'tsx/esm/api'`는 이 .mjs(패키지 node_modules) 기준으로 정적 해소되므로 cwd 무관.
|
|
10
|
+
* (node:module의 register('tsx/esm')는 tsx v4가 deprecated --loader로 간주해 거부 → tsx 자체 API 사용.)
|
|
11
|
+
* (Stage B에서 init.ts를 JS로 빌드하면 이 런처는 제거 가능.)
|
|
12
|
+
*/
|
|
13
|
+
import { register } from 'tsx/esm/api'
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
register()
|
|
18
|
+
|
|
19
|
+
const initTs = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), 'init.ts')).href
|
|
20
|
+
const mod = await import(initTs)
|
|
21
|
+
mod.main(process.argv.slice(2))
|
package/bin/init.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* commitgate init — AI REQ workflow(커밋 게이트) kit을 대상 git repo에 설치(Stage A / Model A: vendored 스캐폴딩).
|
|
4
|
+
*
|
|
5
|
+
* 동작(멱등·비파괴):
|
|
6
|
+
* 1. 대상 repo 감사(git repo·package.json 필수 → 없으면 fail-closed throw)
|
|
7
|
+
* 2. `scripts/req/**` + `workflow/{machine,req.config}.schema.json` 복사(기존 파일은 --force 없으면 스킵)
|
|
8
|
+
* 3. `req.config.json` 시드(부재 시): 감지한 packageManager + handoffPath:null(프로젝트별 값은 코어 DEFAULTS가 아니라 config에서 흡수)
|
|
9
|
+
* 4. 대상 `package.json`에 req:* 스크립트·devDeps(ajv/tsx) 주입(기존 키 미덮어씀)
|
|
10
|
+
* 5. `AGENTS.md` 부재 시 템플릿 생성(있으면 스킵 — Codex 계약 보존)
|
|
11
|
+
*
|
|
12
|
+
* 코어 승인 바인딩·staged tree 검증은 건드리지 않는다(복사만). 프로젝트 차이는 req.config.json에서만 흡수.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
statSync,
|
|
21
|
+
copyFileSync,
|
|
22
|
+
realpathSync,
|
|
23
|
+
} from 'node:fs'
|
|
24
|
+
import { resolve, join, dirname, relative } from 'node:path'
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
26
|
+
import { loadConfig, stripBom, type PackageManager } from '../scripts/req/lib/config'
|
|
27
|
+
import { createGitAdapter } from '../scripts/req/lib/adapters'
|
|
28
|
+
|
|
29
|
+
/** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
|
|
30
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
31
|
+
|
|
32
|
+
/** 대상 package.json에 주입할 req:* 스크립트. */
|
|
33
|
+
const REQ_SCRIPTS: Record<string, string> = {
|
|
34
|
+
'req:new': 'tsx scripts/req/req-new.ts',
|
|
35
|
+
'req:review-codex': 'tsx scripts/req/review-codex.ts',
|
|
36
|
+
'req:doctor': 'tsx scripts/req/req-doctor.ts',
|
|
37
|
+
'req:commit': 'tsx scripts/req/req-commit.ts',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 대상 package.json에 주입할 devDeps(워크플로 실행 전제). cross-spawn = 복사된 adapters.ts의 안전 spawn(P1) 런타임 의존. */
|
|
41
|
+
const REQ_DEV_DEPS: Record<string, string> = {
|
|
42
|
+
ajv: '^8.20.0',
|
|
43
|
+
'cross-spawn': '^7.0.6',
|
|
44
|
+
tsx: '^4.19.1',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InitOptions {
|
|
48
|
+
dir: string
|
|
49
|
+
force: boolean
|
|
50
|
+
dryRun: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InitResult {
|
|
54
|
+
targetRoot: string
|
|
55
|
+
copied: string[] // repo-상대 경로(신규 복사)
|
|
56
|
+
skipped: string[] // repo-상대 경로(이미 존재 → 미덮어씀)
|
|
57
|
+
configAction: 'created' | 'merged' | 'unchanged' // req.config.json: 신규 생성 / 누락키 병합 / 변경 없음
|
|
58
|
+
configKeysAdded: string[] // 병합 시 추가된 키(handoffPath·packageManager)
|
|
59
|
+
packageJsonAdded: string[] // 추가된 script/devDep 키
|
|
60
|
+
agentsCreated: boolean
|
|
61
|
+
packageManager: PackageManager
|
|
62
|
+
dryRun: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 대상이 진짜 git work tree인지 실제 git으로 검증(D5, design R1 P2). `.git` 경로 존재만으론 부족(fake 마커 통과).
|
|
67
|
+
* targetRoot가 repo top-level과 일치해야 함(하위 디렉터리에 스캐폴드 방지). git 미설치/비-repo → throw(fail-closed).
|
|
68
|
+
*/
|
|
69
|
+
function assertGitWorkTree(targetRoot: string): void {
|
|
70
|
+
const git = createGitAdapter(targetRoot)
|
|
71
|
+
let inside: string
|
|
72
|
+
let topLevel: string
|
|
73
|
+
try {
|
|
74
|
+
inside = git.exec(['rev-parse', '--is-inside-work-tree'])
|
|
75
|
+
topLevel = git.exec(['rev-parse', '--show-toplevel'])
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(`대상이 git repo가 아님: ${targetRoot} — 'git init' 후 재시도(워크플로는 git 전제).`)
|
|
78
|
+
}
|
|
79
|
+
if (inside !== 'true') throw new Error(`대상이 git work tree가 아님: ${targetRoot}`)
|
|
80
|
+
// Windows 임시경로(8.3 short name·drive/컴포넌트 case)·symlink 차이 정규화.
|
|
81
|
+
// realpathSync.native = OS API라 컴포넌트 실제 case까지 canonical(WINDOWS/TEMP → Windows/Temp).
|
|
82
|
+
const norm = (p: string): string => resolve(realpathSync.native(p))
|
|
83
|
+
if (norm(topLevel) !== norm(targetRoot))
|
|
84
|
+
throw new Error(`대상이 git repo 최상위가 아님: ${targetRoot} (top-level=${topLevel}) — repo 루트에서 실행.`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** lockfile로 대상 패키지매니저 감지(없으면 npm — 가장 보편적 기본). */
|
|
88
|
+
export function detectPackageManager(root: string): PackageManager {
|
|
89
|
+
if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
90
|
+
if (existsSync(join(root, 'yarn.lock'))) return 'yarn'
|
|
91
|
+
if (existsSync(join(root, 'package-lock.json'))) return 'npm'
|
|
92
|
+
return 'npm'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** dir 하위 모든 파일의 절대경로(재귀). */
|
|
96
|
+
function walkFiles(dir: string): string[] {
|
|
97
|
+
const out: string[] = []
|
|
98
|
+
for (const entry of readdirSync(dir)) {
|
|
99
|
+
const abs = join(dir, entry)
|
|
100
|
+
if (statSync(abs).isDirectory()) out.push(...walkFiles(abs))
|
|
101
|
+
else out.push(abs)
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* srcAbs 파일들을 targetRoot 하위 동일 상대경로로 복사. 기존 파일은 force 없으면 스킵.
|
|
108
|
+
* srcBase는 상대경로 계산 기준(= PACKAGE_ROOT)이라 대상에서도 같은 레이아웃 유지.
|
|
109
|
+
*/
|
|
110
|
+
function copyInto(
|
|
111
|
+
srcFiles: string[],
|
|
112
|
+
srcBase: string,
|
|
113
|
+
targetRoot: string,
|
|
114
|
+
opts: InitOptions,
|
|
115
|
+
copied: string[],
|
|
116
|
+
skipped: string[],
|
|
117
|
+
): void {
|
|
118
|
+
for (const srcAbs of srcFiles) {
|
|
119
|
+
const rel = relative(srcBase, srcAbs).replace(/\\/g, '/')
|
|
120
|
+
const destAbs = join(targetRoot, rel)
|
|
121
|
+
if (existsSync(destAbs) && !opts.force) {
|
|
122
|
+
skipped.push(rel)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
copied.push(rel)
|
|
126
|
+
if (opts.dryRun) continue
|
|
127
|
+
mkdirSync(dirname(destAbs), { recursive: true })
|
|
128
|
+
copyFileSync(srcAbs, destAbs)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** JSON 파일을 객체로 파싱(fail-closed). 파싱 실패·비-객체(배열/원시)면 throw. */
|
|
133
|
+
function parseJsonObject(path: string, label: string): Record<string, unknown> {
|
|
134
|
+
let parsed: unknown
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')))
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new Error(`${label} 파싱 실패(${path}): ${(e as Error).message}`)
|
|
139
|
+
}
|
|
140
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
141
|
+
throw new Error(`${label}이 JSON 객체가 아님(${path})`)
|
|
142
|
+
return parsed as Record<string, unknown>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 설치 코어. IO는 여기서만(테스트가 임시 repo로 직접 호출).
|
|
147
|
+
* **Preflight(전 검증·파싱) → Apply(쓰기) 2단계** — malformed 입력에 대해 어떤 파일도 복사·수정하기 전에 실패한다(부분 설치 방지, design R2 P2).
|
|
148
|
+
*/
|
|
149
|
+
export function runInit(opts: InitOptions): InitResult {
|
|
150
|
+
const targetRoot = resolve(opts.dir)
|
|
151
|
+
|
|
152
|
+
// ══ Preflight: 모든 검증·파싱을 어떤 쓰기보다 먼저 ═══════════════════
|
|
153
|
+
if (!existsSync(targetRoot) || !statSync(targetRoot).isDirectory())
|
|
154
|
+
throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
|
|
155
|
+
assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
|
|
156
|
+
|
|
157
|
+
const pkgPath = join(targetRoot, 'package.json')
|
|
158
|
+
if (!existsSync(pkgPath))
|
|
159
|
+
throw new Error(`package.json 없음: ${targetRoot} — 'npm init' 등으로 먼저 생성(req:* 스크립트 주입 대상).`)
|
|
160
|
+
const pkg = parseJsonObject(pkgPath, 'package.json') as {
|
|
161
|
+
scripts?: Record<string, string>
|
|
162
|
+
devDependencies?: Record<string, string>
|
|
163
|
+
}
|
|
164
|
+
// scripts·devDependencies가 존재하면 반드시 plain object — 배열/원시면 patch가 조용히 유실되어(성공 보고인데 req:* 미주입) fail-closed 위반(phase R1 P2).
|
|
165
|
+
for (const field of ['scripts', 'devDependencies'] as const) {
|
|
166
|
+
const v = (pkg as Record<string, unknown>)[field]
|
|
167
|
+
if (v !== undefined && (typeof v !== 'object' || v === null || Array.isArray(v)))
|
|
168
|
+
throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — req:* 주입 불가(배열/원시 미지원).`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cfgPath = join(targetRoot, 'req.config.json')
|
|
172
|
+
const existingCfg = existsSync(cfgPath) ? parseJsonObject(cfgPath, 'req.config.json') : null
|
|
173
|
+
// 기존 config는 워크플로 CONFIG_SCHEMA(additionalProperties·enum·type) + 경로 confinement까지 preflight 검증(phase R2 P2).
|
|
174
|
+
// kit의 loadConfig를 재사용 — schema-invalid(unknown key·bad enum·escaping ticketRoot 등)면 복사 전 throw(첫 req:* 지연 실패 방지).
|
|
175
|
+
// 병합은 유효 키만 추가(handoffPath:null·packageManager)라 "기존 유효 ⇒ 병합 유효".
|
|
176
|
+
loadConfig({ root: targetRoot })
|
|
177
|
+
const packageManager = detectPackageManager(targetRoot)
|
|
178
|
+
|
|
179
|
+
// req.config.json 계획(쓰기 없음). handoffPath:null·packageManager를 항상 보장 —
|
|
180
|
+
// 코어 DEFAULTS의 palm 고유값(handoffPath)이 기존 부분 config에서도 resurface하지 않도록(design R1 P2). 기존 키 보존.
|
|
181
|
+
let configAction: 'created' | 'merged' | 'unchanged' = 'unchanged'
|
|
182
|
+
const configKeysAdded: string[] = []
|
|
183
|
+
let configToWrite: Record<string, unknown> | null = null
|
|
184
|
+
if (existingCfg === null) {
|
|
185
|
+
configAction = 'created'
|
|
186
|
+
configKeysAdded.push('packageManager', 'handoffPath')
|
|
187
|
+
configToWrite = { packageManager, handoffPath: null }
|
|
188
|
+
} else {
|
|
189
|
+
const patch: Record<string, unknown> = {}
|
|
190
|
+
if (!('handoffPath' in existingCfg)) {
|
|
191
|
+
patch.handoffPath = null
|
|
192
|
+
configKeysAdded.push('handoffPath')
|
|
193
|
+
}
|
|
194
|
+
if (!('packageManager' in existingCfg)) {
|
|
195
|
+
patch.packageManager = packageManager
|
|
196
|
+
configKeysAdded.push('packageManager')
|
|
197
|
+
}
|
|
198
|
+
if (configKeysAdded.length > 0) {
|
|
199
|
+
configAction = 'merged'
|
|
200
|
+
configToWrite = { ...existingCfg, ...patch }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// package.json 패치 계획(쓰기 없음, 기존 키 미덮어씀)
|
|
205
|
+
const packageJsonAdded: string[] = []
|
|
206
|
+
const scripts = pkg.scripts ?? {}
|
|
207
|
+
const devDeps = pkg.devDependencies ?? {}
|
|
208
|
+
for (const [k, v] of Object.entries(REQ_SCRIPTS)) {
|
|
209
|
+
if (!(k in scripts)) {
|
|
210
|
+
scripts[k] = v
|
|
211
|
+
packageJsonAdded.push(`scripts.${k}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const [k, v] of Object.entries(REQ_DEV_DEPS)) {
|
|
215
|
+
if (!(k in devDeps)) {
|
|
216
|
+
devDeps[k] = v
|
|
217
|
+
packageJsonAdded.push(`devDependencies.${k}`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const agentsPath = join(targetRoot, 'AGENTS.md')
|
|
222
|
+
const agentsCreated = !existsSync(agentsPath)
|
|
223
|
+
|
|
224
|
+
// ══ Apply: 여기부터 쓰기(preflight 전부 통과 후에만) ═════════════════
|
|
225
|
+
const copied: string[] = []
|
|
226
|
+
const skipped: string[] = []
|
|
227
|
+
copyInto(walkFiles(join(PACKAGE_ROOT, 'scripts', 'req')), PACKAGE_ROOT, targetRoot, opts, copied, skipped)
|
|
228
|
+
const schemaFiles = ['machine.schema.json', 'req.config.schema.json'].map((f) => join(PACKAGE_ROOT, 'workflow', f))
|
|
229
|
+
copyInto(schemaFiles, PACKAGE_ROOT, targetRoot, opts, copied, skipped)
|
|
230
|
+
|
|
231
|
+
if (!opts.dryRun) {
|
|
232
|
+
if (configToWrite) writeFileSync(cfgPath, JSON.stringify(configToWrite, null, 2) + '\n', 'utf8')
|
|
233
|
+
if (packageJsonAdded.length > 0) {
|
|
234
|
+
pkg.scripts = scripts
|
|
235
|
+
pkg.devDependencies = devDeps
|
|
236
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
|
237
|
+
}
|
|
238
|
+
if (agentsCreated) copyFileSync(join(PACKAGE_ROOT, 'AGENTS.template.md'), agentsPath)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
targetRoot,
|
|
243
|
+
copied,
|
|
244
|
+
skipped,
|
|
245
|
+
configAction,
|
|
246
|
+
configKeysAdded,
|
|
247
|
+
packageJsonAdded,
|
|
248
|
+
agentsCreated,
|
|
249
|
+
packageManager,
|
|
250
|
+
dryRun: opts.dryRun,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function parseArgs(argv: string[]): InitOptions {
|
|
255
|
+
let dir = process.cwd()
|
|
256
|
+
let force = false
|
|
257
|
+
let dryRun = false
|
|
258
|
+
for (let i = 0; i < argv.length; i++) {
|
|
259
|
+
const a = argv[i]
|
|
260
|
+
if (a === '--dir') {
|
|
261
|
+
const v = argv[i + 1]
|
|
262
|
+
if (v === undefined) throw new Error('--dir 값 누락')
|
|
263
|
+
dir = v
|
|
264
|
+
i++
|
|
265
|
+
} else if (a === '--force') {
|
|
266
|
+
force = true
|
|
267
|
+
} else if (a === '--dry-run') {
|
|
268
|
+
dryRun = true
|
|
269
|
+
} else if (a === '-h' || a === '--help') {
|
|
270
|
+
printHelp()
|
|
271
|
+
process.exit(0)
|
|
272
|
+
} else {
|
|
273
|
+
throw new Error(`알 수 없는 인자: ${a}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { dir: resolve(dir), force, dryRun }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function printHelp(): void {
|
|
280
|
+
console.log(`commitgate — AI REQ workflow(커밋 게이트) kit 설치
|
|
281
|
+
|
|
282
|
+
사용법:
|
|
283
|
+
npx commitgate [--dir <대상repo>] [--force] [--dry-run]
|
|
284
|
+
|
|
285
|
+
옵션:
|
|
286
|
+
--dir <path> 대상 repo 루트(기본: 현재 디렉터리)
|
|
287
|
+
--force 기존 kit 파일 덮어쓰기(기본: 스킵)
|
|
288
|
+
--dry-run 변경 없이 수행 예정 목록만 출력
|
|
289
|
+
-h, --help 도움말
|
|
290
|
+
|
|
291
|
+
설치 후:
|
|
292
|
+
1. <대상repo>에서 의존성 설치(감지된 패키지매니저)
|
|
293
|
+
2. codex CLI 설치 확인(리뷰 실호출용)
|
|
294
|
+
3. req.config.json 조정(branchPrefix/ticketRoot 등)
|
|
295
|
+
4. <pm> req:new <slug> --run 으로 첫 티켓 생성`)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function main(argv: string[]): void {
|
|
299
|
+
const opts = parseArgs(argv)
|
|
300
|
+
const r = runInit(opts)
|
|
301
|
+
const tag = r.dryRun ? '[dry-run] ' : ''
|
|
302
|
+
console.log(`${tag}commitgate 설치: ${r.targetRoot}`)
|
|
303
|
+
console.log(`${tag} packageManager 감지: ${r.packageManager}`)
|
|
304
|
+
console.log(`${tag} 복사 ${r.copied.length}개 / 스킵(기존) ${r.skipped.length}개`)
|
|
305
|
+
for (const f of r.copied) console.log(`${tag} + ${f}`)
|
|
306
|
+
for (const f of r.skipped) console.log(`${tag} = ${f} (이미 존재)`)
|
|
307
|
+
const cfgMsg =
|
|
308
|
+
r.configAction === 'created'
|
|
309
|
+
? `생성(${r.configKeysAdded.join(', ')})`
|
|
310
|
+
: r.configAction === 'merged'
|
|
311
|
+
? `누락키 병합(${r.configKeysAdded.join(', ')})`
|
|
312
|
+
: '변경 없음(기존 유지)'
|
|
313
|
+
console.log(`${tag} req.config.json: ${cfgMsg}`)
|
|
314
|
+
console.log(
|
|
315
|
+
`${tag} package.json: ${r.packageJsonAdded.length > 0 ? '추가 ' + r.packageJsonAdded.join(', ') : '변경 없음'}`,
|
|
316
|
+
)
|
|
317
|
+
console.log(`${tag} AGENTS.md: ${r.agentsCreated ? '템플릿 생성' : '이미 존재(유지)'}`)
|
|
318
|
+
if (!r.dryRun) {
|
|
319
|
+
console.log(`\n다음:`)
|
|
320
|
+
console.log(` 1. cd ${r.targetRoot} && ${r.packageManager} install`)
|
|
321
|
+
console.log(` 2. codex --version # 리뷰 실호출 전제(미설치면 review-codex --run이 fail-closed)`)
|
|
322
|
+
console.log(` 3. req.config.json 확인(branchPrefix 등)`)
|
|
323
|
+
console.log(` 4. ${r.packageManager} req:new <slug> --run`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
328
|
+
if (isMain) main(process.argv.slice(2))
|