commitgate 0.2.2 → 0.3.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.
@@ -30,10 +30,44 @@
30
30
  - 커밋 메시지 컨벤션: `test/feat/fix/refactor/docs/chore` 접두사. `[Codex]`/`[Claude]` 같은 메타 정보 금지(Reviewer 편향 방지).
31
31
  - HIGH 영향 phase는 `req:commit --run` 직전 사용자 확인(`state.user_commit_confirmed`).
32
32
 
33
+ ### 5. 승인 범위 해석 규칙
34
+
35
+ **승인은 승인받은 문장 그대로만 유효하다.** 한 통제점의 승인은 다음 통제점으로 **이월되지 않는다**.
36
+
37
+ - `PR 생성 승인`은 `PR merge 승인`이 아니다.
38
+ - `merge/push 승인`은 `required status checks bypass 승인`이 아니다.
39
+ - 통합 승인은 릴리즈 승인이 아니고, `tag` 승인은 `publish` 승인이 아니다.
40
+ - **권한이 있다는 사실은 승인이 아니다.** protected branch를 우회할 권한이 있어도, 우회하려면 그 우회 자체를 승인받아야 한다.
41
+ - 승인 문장이 모호하면 확대 해석하지 말고 **다시 묻는다**.
42
+
43
+ > PR을 생략할 수 있다는 것과, 우회를 보고하지 않아도 된다는 것은 다르다. 경로는 선택이지만 **투명성은 선택이 아니다.**
44
+
33
45
  ## 사람에게 보고해야 할 때
34
46
 
47
+ 각 통제점은 **고유한 승인 문장**을 가진다. 그 문장 그대로 승인받지 못했으면 실행하지 않는다.
48
+
49
+ protected branch에 변경을 넣는 경로는 **두 가지**이고 **둘 다 유효**하다. PR은 **의무가 아니라 선택**이다.
50
+
51
+ - **경로 A (PR 경유)**: `I1` → required status checks green → `I2`
52
+ - **경로 B (direct push)**: `B1` → push → **CI 사후 실행**
53
+
54
+ | # | 통제점 | 경로 | 멈추는 시점 | 승인 문장 |
55
+ |---|---|---|---|---|
56
+ | `I1` | 통합 — PR 열기 | A | feature branch를 원격에 push하고 PR을 생성하기 직전 | `feature branch push + PR 생성 승인` |
57
+ | `I2` | 통합 — PR 머지 | A | **required status checks가 전부 green으로 끝난 것을 확인한 뒤**, PR을 protected branch에 머지하기 직전 | `required checks green 확인 후 PR merge 승인` |
58
+ | `B1` | 통합 — direct push | B | protected branch에 **direct push**하기 직전 | `branch protection bypass를 사용한 direct push 승인` |
59
+ | `R1` | 릴리즈 — tag | — | 버전 tag 생성 및 tag push 직전 | `tag 생성·push 승인` |
60
+ | `R2` | 릴리즈 — publish | — | 패키지 publish 직전 | `npm publish 승인` |
61
+ | `R3` | 릴리즈 — release | — | GitHub release 생성 직전 | `GitHub release 생성 승인` |
62
+
63
+ - 경로 A: `I2`는 checks 결과를 본 뒤에만 요청한다 — green 전 선승인은 받지 않는다(승인자가 볼 근거가 아직 없다).
64
+ - 경로 B: **direct push는 required status checks를 우회한다.** 그래서 `B1` 승인이 따로 필요하다. 경로 B를 고르는 것 자체는 잘못이 아니다 — **우회 사실을 숨기는 것**이 잘못이다.
65
+ - **push 전에 멈춰라.** 대상이 protected branch로 알려져 있거나 확인이 안 되면 push하기 전에 보고한다. push 응답의 `remote: Bypassed rule violations`는 우회가 **이미 일어난 뒤**의 사후 신호이므로 사전 정지의 근거가 될 수 없다.
66
+ - **경로 B에서 CI는 사후 검증이다.** push 이후에 돌기 때문에, 그 green은 반영을 *사전에* 막아 준 게 아니다. 보고할 때 이 사실을 생략하지 않는다.
67
+ - `R1`·`R2`·`R3`는 반영(`I2` 또는 `B1`) 이후 **CI green을 확인한 뒤** 각각 따로 요청한다. 경로 B였다면 그 green이 push 뒤에 나왔다는 점을 함께 보고한다. 셋을 하나의 "릴리즈 승인"으로 뭉뚱그리지 않는다.
68
+
69
+ 그 밖에 보고해야 할 때:
35
70
  - HIGH commit 실행 직전
36
- - main merge / push 직전
37
71
  - destructive 작업(reset/clean/force push) 필요
38
72
  - 설계 범위 변경 또는 비목표 추가 필요
39
73
  - Codex 리뷰 BLOCKED(exit 2) 또는 제한된 재시도 후 판단 불명확
package/README.en.md CHANGED
@@ -42,9 +42,11 @@ Proceed automatically:
42
42
  - The review target is only what has been staged with git add.
43
43
  - Do not manually git add state.json or responses/.
44
44
 
45
- Stop for human confirmation only:
45
+ Stop for human confirmation only (each item must be approved by that exact sentence; one approval never carries over to the next step):
46
46
  - Right before req:commit --run
47
- - Before merging to main or pushing
47
+ - [Path A · optional] [I1] Before pushing the feature branch and opening a PR / [I2] Before merging the PR, after confirming the required status checks are green
48
+ - [Path B] [B1] Before a direct push to a protected branch — get a separate "branch protection bypass를 사용한 direct push 승인". This push bypasses the required status checks, and CI runs after it
49
+ - [R1/R2/R3] tag creation and push / npm publish / GitHub release — each approved separately, after CI is green
48
50
  - Before destructive actions such as reset, clean, or force push
49
51
  - When the requested scope must change
50
52
  - When Codex review returns BLOCKED or remains unclear after bounded retries
@@ -64,10 +66,12 @@ Branch: feat/req-2026-002-profile-edit-api
64
66
  Phases:
65
67
  - phase-1: implement PATCH /profile
66
68
  - phase-2: tests and regression checks
67
- Control points: before req:commit --run, before push
69
+ Control points: before req:commit --run / [B1] before a direct push to main (or [I1] open PR → [I2] merge)
68
70
  ```
69
71
 
70
- After that, the agent runs design, implementation, tests, and Codex review. You only confirm at control points such as commit or push.
72
+ After that, the agent runs design, implementation, tests, and Codex review. You only confirm at control points.
73
+
74
+ Both integration paths are valid: **through a PR (optional)** and **direct push**. A PR is not mandatory. But a direct push to a protected branch **bypasses the required status checks**, so it needs a separate "branch protection bypass를 사용한 direct push 승인" — holding bypass permission is not approval. In that case CI runs **after** the push, so its green is post-hoc verification, and the agent must not omit that from its report. tag, npm publish, and GitHub release are control points of their own, requested after CI is green and never bundled with the integration approval. See [AGENTS.template.md](AGENTS.template.md) and [docs/RELEASING.md](docs/RELEASING.md) for the full contract.
71
75
 
72
76
  ---
73
77
 
@@ -114,6 +118,87 @@ npx commitgate --strict
114
118
 
115
119
  If an existing `cross-spawn` is below the verified floor, CommitGate stops before copying files.
116
120
 
121
+ > `workflow/machine.schema.json` and `workflow/req.config.schema.json` are always copied under `workflow/`, regardless of the `ticketRoot` setting in `req.config.json`.
122
+
123
+ ---
124
+
125
+ ## Removing CommitGate
126
+
127
+ First, the important part: **`npx commitgate` is not a global install.** npx downloads the package into the npm cache (`_npx/<hash>/`) and runs it once; it leaves nothing in your global `node_modules` and nothing on your PATH. The real "installation" is the set of files added to your repo, plus the `package.json` changes listed above.
128
+
129
+ Start by previewing the removal plan. This command **deletes nothing**:
130
+
131
+ ```sh
132
+ npx commitgate uninstall
133
+ ```
134
+
135
+ It reads your repo and classifies what it finds: (1) CommitGate-owned files that are byte-identical to the package originals, (2) files that differ and need your review, (3) files that must not be removed automatically, and (4) audit evidence. Then it prints the revert commands that match your commit state. You review them and run the deletions yourself.
136
+
137
+ ### Why isn't removal automatic?
138
+
139
+ `init` **does not record on disk what it created.** At removal time it is therefore impossible to tell apart:
140
+
141
+ - `AGENTS.md` is created **only when absent**. If you already had one, init leaves it alone — so a file init wrote and a file you wrote look identical on disk.
142
+ - `req.config.json` is **merged** (missing keys only) when it already exists. The original is not kept, so the merge cannot be undone.
143
+ - `package.json` only gets keys that are **absent**. A pre-existing `req:doctor` or `cross-spawn` is not CommitGate's. And `ajv`, `cross-spawn`, and `tsx` are devDependencies other packages commonly use too.
144
+ - Your `ticketRoot` (default `workflow/`) accumulates REQ ticket `state.json` and `approvals.jsonl` — this tool's **audit evidence**.
145
+
146
+ Deleting all of that without a ledger would destroy user data. CommitGate installs no git hooks and touches no git config — it is a pure in-tree scaffolder, so git is the source of truth for undoing it.
147
+
148
+ ### If you have not committed the scaffold
149
+
150
+ ```sh
151
+ git status --porcelain -uall # see what was added
152
+ git diff -- package.json # see the injected req:* scripts and devDependencies
153
+ ```
154
+
155
+ Then revert it yourself. Always restore `package.json` from `HEAD`:
156
+
157
+ ```sh
158
+ git checkout HEAD -- package.json
159
+ ```
160
+
161
+ > ⚠️ Without `HEAD`, git restores from the **index**, so after a `git add` the injected `req:*` scripts survive.
162
+ > ⚠️ This command also discards **any other uncommitted edits** to `package.json`. Check the diff first.
163
+
164
+ Delete only the paths `npx commitgate uninstall` listed. Removing `scripts/req/` or `workflow/` as whole directories would also take your own files and your ticket evidence with them.
165
+
166
+ > git does not track empty directories. After deleting the files, `git status` can report a clean tree while empty `scripts/` and `workflow/` directories remain on disk.
167
+
168
+ ### If you already committed the scaffold
169
+
170
+ Revert the commit that introduced it.
171
+
172
+ ```sh
173
+ git log --diff-filter=A --format='%H %s' -- scripts/req/req-new.ts
174
+ git revert <sha>
175
+ ```
176
+
177
+ `npx commitgate uninstall` finds the introducing commit for you. If that commit also contains unrelated work, reverting it undoes that work too — inspect it with `git show <sha>` first. If the scaffold was introduced across several commits, no single revert will undo it.
178
+
179
+ ### Clearing the npx cache (unrelated to your repo)
180
+
181
+ Check for a global install first:
182
+
183
+ ```sh
184
+ npm ls -g commitgate # empty output means it is not installed globally
185
+ npm uninstall -g commitgate # only if you did install it globally
186
+ ```
187
+
188
+ The package npx downloaded stays under `_npx/` in the npm cache.
189
+
190
+ ```powershell
191
+ # Windows (PowerShell)
192
+ Remove-Item -Recurse -Force "$(npm config get cache)\_npx"
193
+ ```
194
+
195
+ ```sh
196
+ # macOS / Linux
197
+ rm -rf "$(npm config get cache)/_npx"
198
+ ```
199
+
200
+ > ⚠️ **`npm cache clean --force` is not a CommitGate removal command.** It empties `_cacache` only and leaves `_npx` intact. It has nothing to do with the scaffolding in your repo.
201
+
117
202
  ---
118
203
 
119
204
  ## Prerequisites
@@ -181,6 +266,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
181
266
  | `npx commitgate` | Install CommitGate into a project |
182
267
  | `npx commitgate --dry-run` | Preview the install plan without writing files |
183
268
  | `npx commitgate --strict` | Treat low `cross-spawn` version warnings as install failures |
269
+ | `npx commitgate uninstall` | Preview the removal plan (read-only — deletes nothing) |
184
270
  | `req:new <slug> --run` | Create a REQ ticket, branch, and design docs |
185
271
  | `req:review-codex <id> --kind design --run` | Review the design |
186
272
  | `req:review-codex <id> --kind phase --run` | Review the implementation |
package/README.md CHANGED
@@ -42,9 +42,11 @@ req:new → 설계문서 작성 → Codex design 리뷰 → 구현·테스트
42
42
  - 리뷰 대상은 git add 한 파일만이다.
43
43
  - `state.json`과 `responses/`는 직접 `git add`하지 않는다.
44
44
 
45
- 멈춰서 확인받을 때:
45
+ 멈춰서 확인받을 때(각 항목은 그 문장 그대로 승인받아야 하며, 한 승인은 다음 단계로 이월되지 않는다):
46
46
  - req:commit --run 직전
47
- - main 병합 또는 push 직전
47
+ - [경로 A · 선택] [I1] feature branch push + PR 생성 직전 / [I2] required checks green 확인 후 PR merge 직전
48
+ - [경로 B] [B1] protected branch에 direct push 직전 — "branch protection bypass를 사용한 direct push 승인"을 따로 받는다. 이 push는 required checks를 우회하고, CI는 사후에 돈다
49
+ - [R1/R2/R3] tag 생성·push / npm publish / GitHub release — CI green 확인 후 각각 별도 승인
48
50
  - reset, clean, force push 같은 destructive 작업 전
49
51
  - 요구사항 범위를 바꿔야 할 때
50
52
  - Codex 리뷰가 BLOCKED를 반환하거나 제한된 재시도 후에도 판단이 불명확할 때
@@ -64,10 +66,12 @@ REQ-2026-002 발행
64
66
  phase:
65
67
  - phase-1: PATCH /profile 구현
66
68
  - phase-2: 테스트와 회귀 확인
67
- 통제점: req:commit --run 직전, push 직전
69
+ 통제점: req:commit --run 직전 / [B1] main direct push 직전 (또는 [I1] PR 생성 → [I2] merge)
68
70
  ```
69
71
 
70
- 이후에는 에이전트가 설계, 구현, 테스트, Codex 리뷰를 진행합니다. 사용자는 커밋이나 push 같은 통제점에서만 확인하면 됩니다.
72
+ 이후에는 에이전트가 설계, 구현, 테스트, Codex 리뷰를 진행합니다. 사용자는 통제점에서만 확인하면 됩니다.
73
+
74
+ main에 반영하는 경로는 **PR 경유(선택)**와 **direct push** 둘 다 유효합니다. PR은 의무가 아닙니다. 다만 protected branch로 직접 push하면 required checks를 **우회**하므로 "branch protection bypass를 사용한 direct push 승인"을 따로 받아야 합니다 — bypass 권한이 있다는 사실은 승인이 아닙니다. 그리고 이때 CI는 push **이후에** 도는 **사후 검증**이라, 그 사실을 보고에서 생략하지 않습니다. tag, npm publish, GitHub release는 반영과 묶이지 않는 별도 통제점이고 CI green 이후에 요청합니다. 자세한 계약은 [AGENTS.template.md](AGENTS.template.md)와 [docs/RELEASING.md](docs/RELEASING.md)를 참고하세요.
71
75
 
72
76
  ---
73
77
 
@@ -114,6 +118,87 @@ npx commitgate --strict
114
118
 
115
119
  기존 `cross-spawn`이 검증 하한보다 낮으면 파일을 복사하기 전에 중단합니다.
116
120
 
121
+ > `workflow/machine.schema.json`과 `workflow/req.config.schema.json`은 `req.config.json`의 `ticketRoot` 설정과 무관하게 **항상 `workflow/` 아래**에 복사됩니다.
122
+
123
+ ---
124
+
125
+ ## 제거하려면
126
+
127
+ 먼저 알아둘 것: **`npx commitgate`는 전역 설치가 아닙니다.** npx는 패키지를 npm 캐시(`_npx/<hash>/`)에 받아 한 번 실행할 뿐이고, 전역 `node_modules`에도 PATH에도 아무것도 남기지 않습니다. 실제 "설치물"은 위 표대로 **대상 repo에 추가된 파일과 `package.json` 변경**입니다.
128
+
129
+ 제거 계획을 먼저 확인하세요. 이 명령은 **아무것도 지우지 않고** 계획만 출력합니다:
130
+
131
+ ```sh
132
+ npx commitgate uninstall
133
+ ```
134
+
135
+ repo를 읽어 (1) CommitGate가 설치한 파일 중 패키지 원본과 바이트가 동일한 것, (2) 편집돼서 직접 확인이 필요한 것, (3) 자동 제거하면 안 되는 것, (4) 감사 증거를 분류해 보여주고, 커밋 여부에 맞는 되돌리기 명령을 출력합니다. 삭제는 사용자가 검토한 뒤 직접 실행합니다.
136
+
137
+ ### 왜 자동으로 지워주지 않나요?
138
+
139
+ `init`은 **무엇을 새로 만들었는지 디스크에 기록하지 않습니다.** 그래서 제거 시점에는 아래를 구분할 수 없습니다.
140
+
141
+ - `AGENTS.md`는 **없을 때만** 생성됩니다. 이미 있었다면 그대로 두므로, init이 만든 파일과 사용자가 쓴 파일이 디스크상 같아 보입니다.
142
+ - `req.config.json`은 이미 있으면 **누락된 키만 병합**합니다. 원본을 보관하지 않아 병합을 되돌릴 수 없습니다.
143
+ - `package.json`은 **없는 키만** 주입합니다. 원래 있던 `req:doctor`나 `cross-spawn`은 CommitGate 소유가 아닙니다. `ajv`·`cross-spawn`·`tsx`는 다른 패키지도 흔히 쓰는 devDependency입니다.
144
+ - `ticketRoot`(기본 `workflow/`)에는 REQ 티켓의 `state.json`과 `approvals.jsonl` — 이 도구의 **감사 증거** — 가 쌓입니다.
145
+
146
+ 원장이 없는 상태에서 일괄 삭제하면 사용자 데이터를 파괴합니다. CommitGate는 git hook을 설치하지 않고 git config도 건드리지 않는 순수 in-tree 스캐폴더이므로, 되돌리기의 정본은 git입니다.
147
+
148
+ ### 아직 커밋하지 않았다면
149
+
150
+ ```sh
151
+ git status --porcelain -uall # 무엇이 추가됐는지 확인
152
+ git diff -- package.json # 주입된 req:* 스크립트와 devDependencies 확인
153
+ ```
154
+
155
+ 확인한 뒤 직접 되돌립니다. `package.json`은 반드시 `HEAD` 기준으로 복원하세요.
156
+
157
+ ```sh
158
+ git checkout HEAD -- package.json
159
+ ```
160
+
161
+ > ⚠️ `HEAD`를 빼면 **인덱스**에서 복원되어, `git add` 이후에는 주입된 `req:*` 스크립트가 그대로 남습니다.
162
+ > ⚠️ 이 명령은 `package.json`의 **다른 미커밋 편집도 함께 버립니다.** 먼저 위 diff를 확인하세요.
163
+
164
+ 파일 삭제는 `npx commitgate uninstall`이 나열해 준 경로만 지우세요. `scripts/req/`나 `workflow/`를 디렉터리째 지우면 그 안의 사용자 파일이나 티켓 증거가 함께 사라집니다.
165
+
166
+ > git은 빈 디렉터리를 추적하지 않습니다. 파일을 다 지운 뒤 `git status`가 clean이어도 빈 `scripts/`·`workflow/`가 파일시스템에 남을 수 있습니다.
167
+
168
+ ### 이미 커밋했다면
169
+
170
+ 스캐폴딩을 추가한 커밋을 되돌립니다.
171
+
172
+ ```sh
173
+ git log --diff-filter=A --format='%H %s' -- scripts/req/req-new.ts
174
+ git revert <sha>
175
+ ```
176
+
177
+ `npx commitgate uninstall`이 도입 커밋 후보를 찾아 줍니다. 그 커밋에 다른 변경이 섞여 있으면 revert가 무관한 작업까지 되돌리므로, 먼저 `git show <sha>`로 확인하세요. 도입 커밋이 여러 개로 흩어져 있으면 단일 revert로는 되돌릴 수 없습니다.
178
+
179
+ ### npx 캐시 정리 (repo와 무관)
180
+
181
+ 전역 설치 여부부터 확인합니다.
182
+
183
+ ```sh
184
+ npm ls -g commitgate # 비어 있으면 전역 설치가 아님
185
+ npm uninstall -g commitgate # 전역으로 설치했던 경우에만
186
+ ```
187
+
188
+ npx가 받아 둔 패키지는 npm 캐시의 `_npx/` 아래에 남습니다.
189
+
190
+ ```powershell
191
+ # Windows (PowerShell)
192
+ Remove-Item -Recurse -Force "$(npm config get cache)\_npx"
193
+ ```
194
+
195
+ ```sh
196
+ # macOS / Linux
197
+ rm -rf "$(npm config get cache)/_npx"
198
+ ```
199
+
200
+ > ⚠️ **`npm cache clean --force`는 CommitGate 제거 명령이 아닙니다.** 이 명령은 `_cacache`만 비우고 `_npx`는 그대로 둡니다. repo의 스캐폴딩과도 아무 관련이 없습니다.
201
+
117
202
  ---
118
203
 
119
204
  ## 준비물
@@ -181,6 +266,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
181
266
  | `npx commitgate` | 프로젝트에 CommitGate 설치 |
182
267
  | `npx commitgate --dry-run` | 파일을 쓰지 않고 설치 계획 확인 |
183
268
  | `npx commitgate --strict` | 낮은 `cross-spawn` 버전 경고를 설치 실패로 처리 |
269
+ | `npx commitgate uninstall` | 제거 계획 확인 (읽기 전용 — 아무것도 지우지 않음) |
184
270
  | `req:new <slug> --run` | REQ 티켓, 브랜치, 설계문서 생성 |
185
271
  | `req:review-codex <id> --kind design --run` | 설계 리뷰 |
186
272
  | `req:review-codex <id> --kind phase --run` | 구현 리뷰 |
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * commitgate bin 런처(Stage A).
4
- * npm bin은 TS를 직접 실행 못 하므로 tsx ESM 로더를 얹어 init.ts를 실행한다.
4
+ * npm bin은 TS를 직접 실행 못 하므로 tsx ESM 로더를 얹어 init.ts/uninstall.ts를 실행한다.
5
5
  *
6
6
  * ⚠️ tsx는 반드시 **이 런처(=패키지) 기준**으로 해소해야 한다(호출 cwd 아님).
7
7
  * npx로 실행하면 cwd=대상 repo인데 그곳엔 아직 tsx가 없을 수 있어,
@@ -9,6 +9,9 @@
9
9
  * `import ... from 'tsx/esm/api'`는 이 .mjs(패키지 node_modules) 기준으로 정적 해소되므로 cwd 무관.
10
10
  * (node:module의 register('tsx/esm')는 tsx v4가 deprecated --loader로 간주해 거부 → tsx 자체 API 사용.)
11
11
  * (Stage B에서 init.ts를 JS로 빌드하면 이 런처는 제거 가능.)
12
+ *
13
+ * verb dispatch(REQ-2026-007): `uninstall`만 별도 모듈로 보낸다. verb 없는 호출은 **현행 그대로 init**(하위호환).
14
+ * dispatch를 여기(단일 bin 진입점)에 두면 uninstall.ts → init.ts 단방향 import가 되어 순환이 생기지 않는다.
12
15
  */
13
16
  import { register } from 'tsx/esm/api'
14
17
  import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -16,7 +19,11 @@ import { dirname, join } from 'node:path'
16
19
 
17
20
  register()
18
21
 
19
- const initTs = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), 'init.ts')).href
20
- const mod = await import(initTs)
22
+ const binDir = dirname(fileURLToPath(import.meta.url))
23
+ const argv = process.argv.slice(2)
24
+ const entry = argv[0] === 'uninstall' ? 'uninstall.ts' : 'init.ts'
25
+ const rest = argv[0] === 'uninstall' ? argv.slice(1) : argv
26
+
27
+ const mod = await import(pathToFileURL(join(binDir, entry)).href)
21
28
  // runCli = 예외를 친절한 한 줄 메시지 + exit 1로 변환하는 CLI 경계(스택트레이스 노출 방지).
22
- mod.runCli(process.argv.slice(2))
29
+ mod.runCli(rest)
package/bin/init.ts CHANGED
@@ -29,10 +29,20 @@ import { createGitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
29
29
  import * as semver from 'semver'
30
30
 
31
31
  /** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
32
- const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
32
+ export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
33
+
34
+ /** kit 소스 디렉터리(패키지-상대 = 대상-상대). copyInto가 이 레이아웃을 그대로 재현. */
35
+ export const KIT_SOURCE_DIR_REL = 'scripts/req'
36
+
37
+ /**
38
+ * init이 **실제로 복사하는** 스키마 경로(패키지-상대 = 대상-상대). ⚠️ `req.config.json`의 `ticketRoot`/`schemaPath`와 무관하게
39
+ * 언제나 리터럴 `workflow/` 아래다 — `copyInto`가 `relative(PACKAGE_ROOT, src)`로 상대경로를 재현하기 때문.
40
+ * runInit(복사)과 uninstall planner(제거 후보)가 이 상수를 **공유**해야 드리프트가 없다(REQ-2026-007 design R1 P2 / D3b).
41
+ */
42
+ export const KIT_SCHEMA_RELPATHS = ['workflow/machine.schema.json', 'workflow/req.config.schema.json'] as const
33
43
 
34
44
  /** 대상 package.json에 주입할 req:* 스크립트. */
35
- const REQ_SCRIPTS: Record<string, string> = {
45
+ export const REQ_SCRIPTS: Record<string, string> = {
36
46
  'req:new': 'tsx scripts/req/req-new.ts',
37
47
  'req:review-codex': 'tsx scripts/req/review-codex.ts',
38
48
  'req:doctor': 'tsx scripts/req/req-doctor.ts',
@@ -43,7 +53,7 @@ const REQ_SCRIPTS: Record<string, string> = {
43
53
  const CROSS_SPAWN_SPEC = '^7.0.6'
44
54
 
45
55
  /** 대상 package.json에 주입할 devDeps(워크플로 실행 전제). cross-spawn = 복사된 adapters.ts의 안전 spawn(P1) 런타임 의존. */
46
- const REQ_DEV_DEPS: Record<string, string> = {
56
+ export const REQ_DEV_DEPS: Record<string, string> = {
47
57
  ajv: '^8.20.0',
48
58
  'cross-spawn': CROSS_SPAWN_SPEC,
49
59
  tsx: '^4.19.1',
@@ -72,14 +82,17 @@ export interface InitResult {
72
82
  /**
73
83
  * 대상이 진짜 git work tree인지 실제 git으로 검증(D5, design R1 P2). `.git` 경로 존재만으론 부족(fake 마커 통과).
74
84
  * targetRoot가 repo top-level과 일치해야 함(하위 디렉터리에 스캐폴드 방지). git 미설치/비-repo → throw(fail-closed).
85
+ *
86
+ * `run` 주입(REQ-2026-007): uninstall planner가 자신의 감시 runner로 이 검증을 통과시켜
87
+ * **모든 git 호출을 단일 경계에서 관측**할 수 있게 한다. 미지정 시 기존 quiet runner(동작 불변).
75
88
  */
76
- function assertGitWorkTree(targetRoot: string): void {
89
+ export function assertGitWorkTree(targetRoot: string, run?: GitRunner): void {
77
90
  // probe 전용 runner: 비-repo일 때 git이 뱉는 `fatal: not a git repository` stderr를 삼킨다.
78
91
  // 우리가 더 명확한 조치 메시지로 대체하므로 raw git stderr는 노이즈일 뿐(design 후속 UX).
79
92
  // ⚠️ 전역 GitAdapter 기본(stderr 상속)은 그대로 — 다른 git 호출(req:commit 등)의 진단 손실 방지.
80
93
  const quietRunner: GitRunner = (file, args, opts) =>
81
94
  execFileSync(file, args, { ...opts, stdio: ['ignore', 'pipe', 'ignore'] })
82
- const git = createGitAdapter(targetRoot, quietRunner)
95
+ const git = createGitAdapter(targetRoot, run ?? quietRunner)
83
96
  let inside: string
84
97
  let topLevel: string
85
98
  try {
@@ -297,7 +310,7 @@ export function runInit(opts: InitOptions): InitResult {
297
310
  const packageManager = detectPackageManager(targetRoot)
298
311
 
299
312
  // req.config.json 계획(쓰기 없음). handoffPath:null·packageManager를 항상 보장 —
300
- // 코어 DEFAULTS의 palm 고유값(handoffPath) 기존 부분 config에서도 resurface하지 않도록(design R1 P2). 기존 키 보존.
313
+ // handoffPath는 프로젝트별 값이라 코어 기본이 비활성(null)이다 비활성을 config **명시 기록**한다(암묵 < 명시). 기존 키 보존.
301
314
  let configAction: 'created' | 'merged' | 'unchanged' = 'unchanged'
302
315
  const configKeysAdded: string[] = []
303
316
  let configToWrite: Record<string, unknown> | null = null
@@ -344,8 +357,9 @@ export function runInit(opts: InitOptions): InitResult {
344
357
  // ══ Apply: 여기부터 쓰기(preflight 전부 통과 후에만) ═════════════════
345
358
  const copied: string[] = []
346
359
  const skipped: string[] = []
347
- copyInto(walkFiles(join(PACKAGE_ROOT, 'scripts', 'req')), PACKAGE_ROOT, targetRoot, opts, copied, skipped)
348
- const schemaFiles = ['machine.schema.json', 'req.config.schema.json'].map((f) => join(PACKAGE_ROOT, 'workflow', f))
360
+ copyInto(walkFiles(join(PACKAGE_ROOT, KIT_SOURCE_DIR_REL)), PACKAGE_ROOT, targetRoot, opts, copied, skipped)
361
+ // ⚠️ KIT_SCHEMA_RELPATHS는 패키지-상대 = 대상-상대(리터럴 `workflow/`). ticketRoot/schemaPath 설정과 무관 — uninstall planner와 공유하는 SSOT.
362
+ const schemaFiles = KIT_SCHEMA_RELPATHS.map((rel) => join(PACKAGE_ROOT, rel))
349
363
  copyInto(schemaFiles, PACKAGE_ROOT, targetRoot, opts, copied, skipped)
350
364
 
351
365
  if (!opts.dryRun) {
@@ -405,6 +419,7 @@ function printHelp(): void {
405
419
 
406
420
  사용법:
407
421
  npx commitgate [--dir <대상repo>] [--force] [--dry-run] [--strict]
422
+ npx commitgate uninstall [--dir <대상repo>] # 제거 계획만 출력(아무것도 지우지 않음)
408
423
 
409
424
  옵션:
410
425
  --dir <path> 대상 repo 루트(기본: 현재 디렉터리)
@@ -0,0 +1,551 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * commitgate uninstall — **읽기 전용 removal planner** (REQ-2026-007).
4
+ *
5
+ * 이 모듈은 아무것도 지우지 않는다. repo를 읽어 제거 계획과 "사용자가 직접 검토 후 실행할 명령"만 출력한다.
6
+ *
7
+ * 왜 실제 삭제를 하지 않는가:
8
+ * `runInit`은 무엇을 새로 만들었고(copied) 무엇이 이미 있었는지(skipped/merged)를 계산만 하고 **디스크에 원장을 남기지 않는다**.
9
+ * 따라서 uninstall 시점에 `AGENTS.md`(부재 시에만 생성)·`req.config.json`(누락 키만 병합)·`package.json`(부재 키만 주입)이
10
+ * CommitGate 소유인지 사용자 소유인지 구분할 수 없다. 원장 없는 blind delete는 사용자 데이터를 파괴한다.
11
+ * CommitGate는 git hook·git config를 건드리지 않는 **순수 in-tree 스캐폴더**이므로 되돌리기의 정본은 git이다.
12
+ *
13
+ * 읽기 전용 계약(테스트로 고정 — tests/unit/uninstall.test.ts):
14
+ * - `node:fs`에서 조회 API만 가져온다(파일을 만들거나 고치거나 지우는 API를 import하지 않는다).
15
+ * - git은 read-only 서브커맨드 allowlist(`rev-parse`·`status`·`ls-files`·`log`)만 호출한다.
16
+ * - 해시는 `node:crypto`로 계산한다(`git hash-object`는 objects/에 쓸 수 있어 쓰지 않는다).
17
+ * - npm을 spawn하지 않는다. 캐시 정리 명령은 문자열로 출력만 한다.
18
+ * - 삭제 플래그(`--run`/`--force`)를 제공하지 않는다 — 이 부재가 계약이다.
19
+ */
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
21
+ import { createHash } from 'node:crypto'
22
+ import { resolve, join, relative } from 'node:path'
23
+ import { pathToFileURL } from 'node:url'
24
+ import { loadConfig, stripBom, DEFAULTS } from '../scripts/req/lib/config'
25
+ import { createGitAdapter, type GitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
26
+ import {
27
+ PACKAGE_ROOT,
28
+ KIT_SOURCE_DIR_REL,
29
+ KIT_SCHEMA_RELPATHS,
30
+ REQ_SCRIPTS,
31
+ REQ_DEV_DEPS,
32
+ assertGitWorkTree,
33
+ } from './init'
34
+
35
+ export interface UninstallOptions {
36
+ dir: string
37
+ }
38
+
39
+ /** CommitGate가 복사한 파일(kit 소스 + init이 실제로 복사한 스키마). 바이트 비교로 무결성만 표기. */
40
+ export interface ToolArtifact {
41
+ path: string // repo-상대
42
+ present: boolean
43
+ /** 현재 실행 중인 패키지의 원본과 바이트 동일? `differs`는 "편집됐거나 **다른 버전**이 설치함"일 뿐 사용자 소유 단정이 아니다. */
44
+ match: 'identical' | 'differs' | 'absent'
45
+ tracked: boolean
46
+ /**
47
+ * 이 경로를 추가(A)한 **가장 최근** 커밋 sha. **현재 tracked인 파일에 한해서만** 채운다(phase R2 P2).
48
+ * 과거에 추가됐다가 삭제된 경로는 `git log --diff-filter=A`에 낡은 add 커밋이 남아 있어,
49
+ * 새로 설치한 untracked 파일을 "커밋됨"으로 오판하고 엉뚱한 커밋의 revert를 권하게 된다.
50
+ */
51
+ introducedBy: string | null
52
+ }
53
+
54
+ /** origin 판별 불가 → **항상 자동 제거 대상에서 제외**. `path`는 파일 또는 `package.json#scripts.req:new` 형태. */
55
+ export interface AmbiguousArtifact {
56
+ path: string
57
+ present: boolean
58
+ note: string
59
+ }
60
+
61
+ /** 설정된 ticketRoot — REQ 티켓 state.json·approvals.jsonl 등 감사 증거. */
62
+ export interface EvidenceDir {
63
+ path: string
64
+ ticketCount: number
65
+ }
66
+
67
+ export interface ScaffoldCommit {
68
+ sha: string
69
+ subject: string
70
+ }
71
+
72
+ export interface UninstallFacts {
73
+ targetRoot: string
74
+ /** `loadConfig` 해소값. 증거 보호 축(설치 경로 축은 KIT_SCHEMA_RELPATHS). */
75
+ ticketRoot: string
76
+ schemaPath: string
77
+ /** config를 읽지 못해 DEFAULTS로 강등했으면 사유. planner는 쓰기가 없으므로 강등이 안전하다. */
78
+ configError: string | null
79
+ installed: boolean
80
+ packageJsonDirty: boolean
81
+ tool: ToolArtifact[]
82
+ ambiguous: AmbiguousArtifact[]
83
+ evidence: EvidenceDir[]
84
+ info: string[]
85
+ /**
86
+ * kit 디렉터리(`scripts/req/`) 안에 있지만 이 패키지가 설치한 파일이 **아닌** 것들(phase R1 P1).
87
+ * 사용자가 직접 넣은 파일일 수 있으므로 제거 후보에 넣지 않고, 디렉터리 통삭제도 제안하지 않는다.
88
+ */
89
+ unknownKitFiles: string[]
90
+ }
91
+
92
+ export type UninstallMode = 'not-installed' | 'uncommitted' | 'committed' | 'mixed'
93
+
94
+ export interface UninstallPlan {
95
+ facts: UninstallFacts
96
+ mode: UninstallMode
97
+ /** 패키지 원본과 바이트 동일 → 사용자가 지워도 잃을 것이 없는 파일. */
98
+ removable: ToolArtifact[]
99
+ /** 원본과 다름 → 사용자 검토 후 판단. */
100
+ review: ToolArtifact[]
101
+ /** ambiguous 중 실제로 존재하는 것 — 자동 제거 금지 목록. */
102
+ keep: AmbiguousArtifact[]
103
+ /** 티켓이 실제로 쌓인 증거 디렉터리 — 삭제 금지. */
104
+ protect: EvidenceDir[]
105
+ scaffoldCommits: ScaffoldCommit[]
106
+ }
107
+
108
+ const TICKET_DIR_RE = /^REQ-\d{4}-\d+$/
109
+
110
+ // ─────────────────────────────────────────────────────── 읽기 헬퍼 ──
111
+
112
+ function sha256(abs: string): string {
113
+ return createHash('sha256').update(readFileSync(abs)).digest('hex')
114
+ }
115
+
116
+ /** dir 하위 모든 파일의 절대경로(재귀, 조회만). */
117
+ function walkFiles(dir: string): string[] {
118
+ const out: string[] = []
119
+ for (const entry of readdirSync(dir)) {
120
+ const abs = join(dir, entry)
121
+ if (statSync(abs).isDirectory()) out.push(...walkFiles(abs))
122
+ else out.push(abs)
123
+ }
124
+ return out
125
+ }
126
+
127
+ const toRel = (abs: string): string => relative(PACKAGE_ROOT, abs).replace(/\\/g, '/')
128
+
129
+ /** git이 추적 중인가(인덱스에 있는가). */
130
+ function isTracked(git: GitAdapter, rel: string): boolean {
131
+ return git.exec(['ls-files', '--', rel]).length > 0
132
+ }
133
+
134
+ /** 이 경로를 처음 추가한 커밋(sha, subject). HEAD 이력에 없으면 null. */
135
+ function introducingCommit(git: GitAdapter, rel: string): ScaffoldCommit | null {
136
+ const out = git.exec(['log', '--diff-filter=A', '-n', '1', '--format=%H%x09%s', '--', rel])
137
+ if (!out) return null
138
+ const tab = out.indexOf('\t')
139
+ if (tab < 0) return null
140
+ return { sha: out.slice(0, tab), subject: out.slice(tab + 1) }
141
+ }
142
+
143
+ /** JSON 객체 파싱(실패/비-객체면 null — planner는 fail-closed 하지 않고 "확인 불가"로 표기). */
144
+ function readJsonObject(abs: string): Record<string, unknown> | null {
145
+ try {
146
+ const v: unknown = JSON.parse(stripBom(readFileSync(abs, 'utf8')))
147
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
148
+ } catch {
149
+ return null
150
+ }
151
+ }
152
+
153
+ function stringMap(obj: Record<string, unknown> | null, key: string): Record<string, string> {
154
+ const v = obj?.[key]
155
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, string>
156
+ return {}
157
+ }
158
+
159
+ // ──────────────────────────────────────────────── 사실 수집 (IO=읽기) ──
160
+
161
+ /**
162
+ * 대상 repo를 읽어 `UninstallFacts`를 만든다. **읽기만 한다.**
163
+ * `run` 주입 시 모든 git 호출이 그 runner를 통과한다(테스트가 서브커맨드를 감시).
164
+ */
165
+ export function collectFacts(opts: UninstallOptions, run?: GitRunner): UninstallFacts {
166
+ const targetRoot = resolve(opts.dir)
167
+ if (!existsSync(targetRoot) || !statSync(targetRoot).isDirectory())
168
+ throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
169
+ assertGitWorkTree(targetRoot, run)
170
+ const git = createGitAdapter(targetRoot, run)
171
+
172
+ // 증거 보호 축: config에서 해소. 읽지 못하면 DEFAULTS로 강등하되 그 사실을 알린다
173
+ // (깨진 config 때문에 제거 안내를 못 받는 건 부당하다 — planner는 쓰기가 없어 강등이 안전).
174
+ let ticketRoot = DEFAULTS.ticketRoot
175
+ let schemaPath = DEFAULTS.schemaPath
176
+ let configError: string | null = null
177
+ try {
178
+ const cfg = loadConfig({ root: targetRoot })
179
+ ticketRoot = cfg.ticketRoot
180
+ schemaPath = cfg.schemaPath
181
+ } catch (e) {
182
+ configError = (e as Error).message
183
+ }
184
+
185
+ const info: string[] = []
186
+ if (configError)
187
+ info.push(`req.config.json을 읽을 수 없어 기본값(DEFAULTS)으로 강등했습니다 — ${configError}`)
188
+ // 설치 경로 축(KIT_SCHEMA_RELPATHS)과 설정 경로 축(schemaPath)은 갈라질 수 있다.
189
+ if (!(KIT_SCHEMA_RELPATHS as readonly string[]).includes(schemaPath))
190
+ info.push(
191
+ `schemaPath=${schemaPath} — 런타임이 읽는 경로이지만 init이 복사한 파일이 아닙니다(제거 후보 아님).`,
192
+ )
193
+
194
+ // ── tool: kit 소스 + init이 **실제로 복사한** 스키마(항상 리터럴 workflow/ — ticketRoot 무관)
195
+ const kitSourceRels = walkFiles(join(PACKAGE_ROOT, KIT_SOURCE_DIR_REL)).map(toRel)
196
+ const toolRels = [...kitSourceRels, ...KIT_SCHEMA_RELPATHS]
197
+ const tool: ToolArtifact[] = toolRels.map((rel) => {
198
+ const dest = join(targetRoot, rel)
199
+ const src = join(PACKAGE_ROOT, rel)
200
+ if (!existsSync(dest))
201
+ return { path: rel, present: false, match: 'absent', tracked: false, introducedBy: null }
202
+ const match: ToolArtifact['match'] =
203
+ existsSync(src) && sha256(dest) === sha256(src) ? 'identical' : 'differs'
204
+ const tracked = isTracked(git, rel)
205
+ return {
206
+ path: rel,
207
+ present: true,
208
+ match,
209
+ tracked,
210
+ // untracked면 이력의 add 커밋은 "지금 이 설치본"의 도입 커밋이 아니다 → 조회조차 하지 않는다(phase R2 P2).
211
+ introducedBy: tracked ? (introducingCommit(git, rel)?.sha ?? null) : null,
212
+ }
213
+ })
214
+
215
+ // ── ambiguous: origin 판별 불가 → 자동 제거 대상에서 항상 제외
216
+ const ambiguous: AmbiguousArtifact[] = []
217
+
218
+ const agentsAbs = join(targetRoot, 'AGENTS.md')
219
+ if (existsSync(agentsAbs)) {
220
+ const tpl = join(PACKAGE_ROOT, 'AGENTS.template.md')
221
+ const same = existsSync(tpl) && sha256(agentsAbs) === sha256(tpl)
222
+ ambiguous.push({
223
+ path: 'AGENTS.md',
224
+ present: true,
225
+ note: same
226
+ ? 'init 템플릿과 동일 — 그래도 자동 제거 대상이 아닙니다(Codex 계약 파일)'
227
+ : '템플릿과 다름 — 사용자/팀이 작성했거나 편집했습니다',
228
+ })
229
+ }
230
+
231
+ const cfgAbs = join(targetRoot, 'req.config.json')
232
+ if (existsSync(cfgAbs)) {
233
+ const parsed = readJsonObject(cfgAbs)
234
+ let note: string
235
+ if (!parsed) note = '파싱 불가 — 내용을 직접 확인하세요'
236
+ else {
237
+ const keys = Object.keys(parsed).sort().join(',')
238
+ note =
239
+ keys === 'handoffPath,packageManager' && parsed.handoffPath === null
240
+ ? 'init 시드와 동일 — 그래도 자동 제거 대상이 아닙니다'
241
+ : '사용자 값 포함(init은 누락 키만 병합합니다) — 값을 보존하세요'
242
+ }
243
+ ambiguous.push({ path: 'req.config.json', present: true, note })
244
+ }
245
+
246
+ const pkgAbs = join(targetRoot, 'package.json')
247
+ const pkg = existsSync(pkgAbs) ? readJsonObject(pkgAbs) : null
248
+ const scripts = stringMap(pkg, 'scripts')
249
+ const devDeps = stringMap(pkg, 'devDependencies')
250
+ for (const [k, injected] of Object.entries(REQ_SCRIPTS)) {
251
+ const cur = scripts[k]
252
+ if (cur === undefined) continue
253
+ ambiguous.push({
254
+ path: `package.json#scripts.${k}`,
255
+ present: true,
256
+ note: cur === injected ? 'init 주입값과 동일' : `사용자 값(init 주입값과 다름): ${cur}`,
257
+ })
258
+ }
259
+ for (const [k, injected] of Object.entries(REQ_DEV_DEPS)) {
260
+ const cur = devDeps[k]
261
+ if (cur === undefined) continue
262
+ ambiguous.push({
263
+ path: `package.json#devDependencies.${k}`,
264
+ present: true,
265
+ note:
266
+ cur === injected
267
+ ? 'init 주입값과 동일 — 다른 곳에서도 쓰일 수 있습니다'
268
+ : `사용자 값(init 주입값과 다름): ${cur}`,
269
+ })
270
+ }
271
+
272
+ // ── evidence: 설정된 ticketRoot 하위 REQ-* (하드코딩 workflow/ 아님)
273
+ const evidence: EvidenceDir[] = []
274
+ const ticketRootAbs = join(targetRoot, ticketRoot)
275
+ if (existsSync(ticketRootAbs) && statSync(ticketRootAbs).isDirectory()) {
276
+ const ticketCount = readdirSync(ticketRootAbs, { withFileTypes: true }).filter(
277
+ (d) => d.isDirectory() && TICKET_DIR_RE.test(d.name),
278
+ ).length
279
+ evidence.push({ path: ticketRoot, ticketCount })
280
+ }
281
+
282
+ // ── kit 디렉터리 안의 미분류 파일(phase R1 P1): 사용자가 넣었을 수 있으므로 제거 후보에서 제외하고 통삭제도 금지.
283
+ const kitDirAbs = join(targetRoot, KIT_SOURCE_DIR_REL)
284
+ const knownKit = new Set(kitSourceRels)
285
+ const unknownKitFiles =
286
+ existsSync(kitDirAbs) && statSync(kitDirAbs).isDirectory()
287
+ ? walkFiles(kitDirAbs)
288
+ .map((abs) => relative(targetRoot, abs).replace(/\\/g, '/'))
289
+ .filter((rel) => !knownKit.has(rel))
290
+ .sort()
291
+ : []
292
+
293
+ const packageJsonDirty = existsSync(pkgAbs) && git.exec(['status', '--porcelain', '--', 'package.json']).length > 0
294
+ const installed = tool.some((t) => t.present) || ambiguous.some((a) => a.present)
295
+
296
+ return {
297
+ targetRoot,
298
+ ticketRoot,
299
+ schemaPath,
300
+ configError,
301
+ installed,
302
+ packageJsonDirty,
303
+ tool,
304
+ ambiguous,
305
+ evidence,
306
+ info,
307
+ unknownKitFiles,
308
+ }
309
+ }
310
+
311
+ // ────────────────────────────────────────────────────── 계획 (순수) ──
312
+
313
+ /** facts → plan. **순수 함수**(IO 없음). */
314
+ export function buildPlan(facts: UninstallFacts): UninstallPlan {
315
+ const present = facts.tool.filter((t) => t.present)
316
+ // 도입 커밋은 **현재 tracked인 파일**에서만 인정한다(phase R2 P2 — 낡은 add 커밋으로 인한 오판 차단).
317
+ const introduced = present.filter((t) => t.tracked && t.introducedBy !== null)
318
+
319
+ let mode: UninstallMode
320
+ if (!facts.installed) mode = 'not-installed'
321
+ else if (introduced.length === 0) mode = 'uncommitted'
322
+ else if (introduced.length === present.length) mode = 'committed'
323
+ else mode = 'mixed'
324
+
325
+ // 도입 커밋 후보(중복 제거, 첫 등장 순).
326
+ const seen = new Set<string>()
327
+ const scaffoldCommits: ScaffoldCommit[] = []
328
+ for (const t of introduced) {
329
+ const sha = t.introducedBy as string
330
+ if (seen.has(sha)) continue
331
+ seen.add(sha)
332
+ scaffoldCommits.push({ sha, subject: '' })
333
+ }
334
+
335
+ return {
336
+ facts,
337
+ mode,
338
+ removable: present.filter((t) => t.match === 'identical'),
339
+ review: present.filter((t) => t.match === 'differs'),
340
+ keep: facts.ambiguous.filter((a) => a.present),
341
+ protect: facts.evidence.filter((e) => e.ticketCount > 0),
342
+ scaffoldCommits,
343
+ }
344
+ }
345
+
346
+ /** subject를 붙인 scaffoldCommits(IO=git log). buildPlan은 순수하게 유지하고 여기서만 보강. */
347
+ function enrichCommits(plan: UninstallPlan, git: GitAdapter): UninstallPlan {
348
+ if (plan.scaffoldCommits.length === 0) return plan
349
+ const anchorByS = new Map<string, string>()
350
+ for (const t of plan.facts.tool) {
351
+ if (t.introducedBy && !anchorByS.has(t.introducedBy)) anchorByS.set(t.introducedBy, t.path)
352
+ }
353
+ const scaffoldCommits = plan.scaffoldCommits.map((c) => {
354
+ const anchor = anchorByS.get(c.sha)
355
+ const found = anchor ? introducingCommit(git, anchor) : null
356
+ return { sha: c.sha, subject: found?.subject ?? '' }
357
+ })
358
+ return { ...plan, scaffoldCommits }
359
+ }
360
+
361
+ // ────────────────────────────────────────────────────── 출력 (순수) ──
362
+
363
+ const MODE_LABEL: Record<UninstallMode, string> = {
364
+ 'not-installed': '설치 흔적 없음',
365
+ uncommitted: '설치됨 — 아직 커밋되지 않음',
366
+ committed: '설치됨 — 커밋됨',
367
+ mixed: '설치됨 — 일부만 커밋됨',
368
+ }
369
+
370
+ /** plan → 사람이 읽는 계획 텍스트. **순수 함수**. 여기서 출력하는 명령은 전부 "사용자가 직접 실행할 것"이다. */
371
+ export function renderPlan(plan: UninstallPlan): string {
372
+ const { facts } = plan
373
+ const L: string[] = []
374
+
375
+ L.push('[commitgate uninstall] 읽기 전용 제거 계획 — 이 명령은 어떤 파일도 지우지 않습니다.')
376
+ L.push(` 대상 : ${facts.targetRoot}`)
377
+ L.push(` 상태 : ${MODE_LABEL[plan.mode]}`)
378
+ L.push(` ticketRoot : ${facts.ticketRoot}${facts.configError ? ' (기본값 강등)' : ''}`)
379
+ for (const i of facts.info) L.push(` 참고 : ${i}`)
380
+ L.push('')
381
+
382
+ if (plan.mode === 'not-installed') {
383
+ L.push('이 repo에서 CommitGate 설치 흔적을 찾지 못했습니다. 되돌릴 것이 없습니다.')
384
+ L.push('')
385
+ L.push(renderNpxSection())
386
+ return L.join('\n')
387
+ }
388
+
389
+ L.push('## 1. CommitGate 소유 파일')
390
+ if (plan.removable.length) {
391
+ L.push(' 패키지 원본과 바이트 동일 — 지워도 잃을 내용이 없습니다:')
392
+ for (const t of plan.removable) L.push(` - ${t.path}${t.tracked ? ' (tracked)' : ' (untracked)'}`)
393
+ }
394
+ if (plan.review.length) {
395
+ L.push(' 원본과 다름 — 편집됐거나 다른 버전이 설치했습니다. 지우기 전에 직접 확인하세요:')
396
+ for (const t of plan.review) L.push(` ~ ${t.path}${t.tracked ? ' (tracked)' : ' (untracked)'}`)
397
+ }
398
+ if (!plan.removable.length && !plan.review.length) L.push(' (없음)')
399
+ if (facts.unknownKitFiles.length) {
400
+ L.push(` ${KIT_SOURCE_DIR_REL}/ 안에 CommitGate가 설치하지 않은 파일이 있습니다 — 건드리지 마세요:`)
401
+ for (const f of facts.unknownKitFiles) L.push(` ? ${f}`)
402
+ }
403
+ L.push('')
404
+
405
+ L.push('## 2. 자동 제거 대상이 아님 — 직접 판단하세요')
406
+ L.push(' init은 무엇을 새로 만들었는지 디스크에 기록하지 않습니다. 따라서 아래 항목이')
407
+ L.push(' CommitGate 소유인지 원래 있던 것인지 이 도구는 알 수 없습니다.')
408
+ if (plan.keep.length) for (const a of plan.keep) L.push(` - ${a.path} — ${a.note}`)
409
+ else L.push(' (없음)')
410
+ L.push('')
411
+
412
+ L.push('## 3. 감사 증거 — 삭제하지 마세요')
413
+ if (plan.protect.length)
414
+ for (const e of plan.protect) L.push(` - ${e.path}/ (REQ 티켓 ${e.ticketCount}개 · state.json · approvals.jsonl)`)
415
+ else L.push(` ${facts.ticketRoot}/ 에 REQ 티켓이 아직 없습니다. 티켓이 생기면 이 디렉터리는 감사 증거가 됩니다.`)
416
+ L.push('')
417
+
418
+ L.push('## 4. 되돌리는 방법 (아래 명령은 직접 실행하세요)')
419
+ L.push(...renderRevertSection(plan))
420
+ L.push('')
421
+
422
+ L.push('## 5. 잔여물 경고')
423
+ L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
424
+ L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/)가 파일시스템에 남을 수 있습니다.`)
425
+ L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
426
+ L.push('')
427
+
428
+ L.push(renderNpxSection())
429
+ return L.join('\n')
430
+ }
431
+
432
+ function renderRevertSection(plan: UninstallPlan): string[] {
433
+ const L: string[] = []
434
+ const { facts } = plan
435
+
436
+ if (plan.mode === 'committed' || plan.mode === 'mixed') {
437
+ if (plan.scaffoldCommits.length === 1) {
438
+ const c = plan.scaffoldCommits[0] as ScaffoldCommit
439
+ L.push(' 스캐폴딩 도입 커밋:')
440
+ L.push(` ${c.sha} ${c.subject}`)
441
+ L.push(' 이 커밋이 스캐폴딩만 담고 있다면:')
442
+ L.push(` git revert ${c.sha}`)
443
+ L.push(' 다른 변경이 섞여 있으면 revert가 무관한 작업까지 되돌립니다 — 먼저 `git show`로 확인하세요.')
444
+ } else {
445
+ L.push(' ⚠️ 스캐폴딩 도입 커밋이 여러 개로 흩어져 있어 단일 revert로 되돌릴 수 없습니다:')
446
+ for (const c of plan.scaffoldCommits) L.push(` ${c.sha} ${c.subject}`)
447
+ L.push(' 각 커밋의 내용을 확인한 뒤(`git show <sha>`) 되돌릴 범위를 직접 정하세요.')
448
+ }
449
+ if (plan.mode === 'mixed') L.push(' 일부 파일은 아직 커밋되지 않았습니다 — 아래 미커밋 절차도 함께 보세요.')
450
+ }
451
+
452
+ if (plan.mode === 'uncommitted' || plan.mode === 'mixed') {
453
+ L.push(' 1) 무엇이 바뀌었는지 확인:')
454
+ L.push(' git status --porcelain -uall')
455
+ L.push(' git diff -- package.json')
456
+ if (facts.packageJsonDirty) {
457
+ L.push(' 2) package.json 되돌리기:')
458
+ L.push(' git checkout HEAD -- package.json')
459
+ L.push(' ⚠️ 이 명령은 package.json의 다른 미커밋 편집도 함께 버립니다. 먼저 위 diff를 확인하세요.')
460
+ L.push(' ⚠️ `HEAD`를 빼면 인덱스에서 복원되어, `git add` 이후에는 주입된 req:* 스크립트가 그대로 남습니다.')
461
+ }
462
+ if (plan.removable.length) {
463
+ // ⚠️ 디렉터리 통삭제(`rm -rf <kit dir>`)는 제안하지 않는다(phase R1 P1): 그 디렉터리에 사용자가 넣은
464
+ // 미분류 파일이 있으면 함께 지워진다. **분류된 파일만 파일 단위로** 나열한다.
465
+ L.push(' 3) 원본과 동일한 파일만 삭제(직접 실행):')
466
+ for (const t of plan.removable) L.push(` rm -f ${t.path}`)
467
+ }
468
+ if (plan.review.length) L.push(' 4) 위 "원본과 다름" 파일은 내용을 확인한 뒤 직접 결정하세요.')
469
+ }
470
+ return L
471
+ }
472
+
473
+ function renderNpxSection(): string {
474
+ return [
475
+ '## npx 캐시 (repo 스캐폴딩과 무관 — 별도 정리)',
476
+ ' `npx commitgate`는 전역 설치가 아닙니다. 패키지는 npm 캐시의 `_npx/<hash>/`에만 들어갑니다.',
477
+ ' 확인 : npm ls -g commitgate (비어 있으면 전역 설치 아님)',
478
+ ' 전역이었다면 : npm uninstall -g commitgate',
479
+ ' 캐시에 남은 npx 패키지 정리:',
480
+ ' Windows (PowerShell) : Remove-Item -Recurse -Force "$(npm config get cache)\\_npx"',
481
+ ' macOS / Linux : rm -rf "$(npm config get cache)/_npx"',
482
+ ' ⚠️ `npm cache clean --force`는 `_cacache`만 비우고 `_npx`는 지우지 않습니다 — CommitGate 제거 명령이 아닙니다.',
483
+ ].join('\n')
484
+ }
485
+
486
+ // ─────────────────────────────────────────────────────────── 파사드 ──
487
+
488
+ /** 사실 수집 → 계획. 읽기 전용. `run` 주입 시 모든 git 호출이 그 runner를 통과. */
489
+ export function planUninstall(opts: UninstallOptions, run?: GitRunner): UninstallPlan {
490
+ const facts = collectFacts(opts, run)
491
+ const git = createGitAdapter(resolve(opts.dir), run)
492
+ return enrichCommits(buildPlan(facts), git)
493
+ }
494
+
495
+ /** 계획을 stdout에 출력하고 텍스트를 반환. */
496
+ export function runUninstall(opts: UninstallOptions, run?: GitRunner): string {
497
+ const text = renderPlan(planUninstall(opts, run))
498
+ console.log(text)
499
+ return text
500
+ }
501
+
502
+ export function parseArgs(argv: string[]): UninstallOptions {
503
+ let dir = process.cwd()
504
+ for (let i = 0; i < argv.length; i++) {
505
+ const a = argv[i]
506
+ if (a === '--dir') {
507
+ const v = argv[i + 1]
508
+ if (v === undefined) throw new Error('--dir 값 누락')
509
+ dir = v
510
+ i++
511
+ } else if (a === '-h' || a === '--help') {
512
+ printHelp()
513
+ process.exit(0)
514
+ } else {
515
+ throw new Error(`알 수 없는 인자: ${a}`)
516
+ }
517
+ }
518
+ return { dir: resolve(dir) }
519
+ }
520
+
521
+ function printHelp(): void {
522
+ console.log(`commitgate uninstall — 제거 계획 출력(읽기 전용)
523
+
524
+ 사용법:
525
+ npx commitgate uninstall [--dir <대상repo>]
526
+
527
+ 이 명령은 **아무것도 지우지 않습니다.** repo를 읽어 무엇이 설치됐는지 분류하고,
528
+ 사용자가 직접 검토 후 실행할 git/삭제 명령을 출력합니다.
529
+
530
+ 옵션:
531
+ --dir <path> 대상 repo 루트(기본: 현재 디렉터리)
532
+ -h, --help 도움말
533
+
534
+ 왜 자동 삭제가 없나:
535
+ init은 "무엇을 새로 만들었는지"를 디스크에 기록하지 않습니다. 그래서 AGENTS.md·req.config.json·
536
+ package.json의 값이 CommitGate 소유인지 사용자 소유인지 구분할 수 없고, blind 삭제는 데이터를 파괴합니다.
537
+ 이 kit은 git repo에 파일만 추가하므로 되돌리기의 정본은 git입니다.`)
538
+ }
539
+
540
+ /** CLI 경계: 예상된 실패(throw)를 친절한 한 줄 + exit 1로 변환(스택트레이스 노출 방지). init.ts runCli와 동일 정책. */
541
+ export function runCli(argv: string[]): void {
542
+ try {
543
+ runUninstall(parseArgs(argv))
544
+ } catch (err) {
545
+ console.error(`commitgate uninstall: ${err instanceof Error ? err.message : String(err)}`)
546
+ process.exitCode = 1
547
+ }
548
+ }
549
+
550
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
551
+ if (isMain) runCli(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitgate",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,8 +1,8 @@
1
1
  /**
2
- * req 워크플로 config 모듈 (REQ-2026-017 Phase 1, portability kit).
2
+ * req 워크플로 config 모듈 (portability kit).
3
3
  *
4
- * 목적: 경로·이름·패키지매니저를 `req.config.json`으로 외부화하되, **파일 부재 시 현재 동작 100% 유지**(DEFAULTS=현재 하드코딩 값).
5
- * Phase는 **모듈만** 제공 4 스크립트 배선·`--root` CLI는 Phase 2, 어댑터는 Phase 3.
4
+ * 목적: 경로·이름·패키지매니저를 `req.config.json`으로 외부화한다. 파일이 없으면 `DEFAULTS`로 해소된다.
5
+ * ⚠️ `DEFAULTS`는 **모든 프로젝트에 유효한 중립 기본값**만 담는다(REQ-2026-009). 프로젝트 고유 값은 config가 흡수한다.
6
6
  *
7
7
  * 안전(fail-closed): config가 게이트를 무력화하거나 경로를 탈출하지 못하도록 AJV 스키마 + 해상도 confinement로 강제.
8
8
  */
@@ -48,11 +48,21 @@ export interface ResolvedConfig {
48
48
  handoffPathAbs: string | null
49
49
  }
50
50
 
51
- /** ⚠️ 현재 하드코딩 값 — 변경 시 behavior-preserving(수용기준 #1) 깨짐. */
51
+ /**
52
+ * 코어 기본값. `req.config.json` 부재 시 이 값으로 해소된다.
53
+ *
54
+ * ⚠️ 여기 있는 값은 **모든 대상 프로젝트에 유효한 중립 기본값**이어야 한다.
55
+ * 특정 프로젝트에만 의미 있는 값(경로·문서 위치 등)은 코어가 아니라 `req.config.json`이 흡수한다.
56
+ * `handoffPath`가 그 예다 — 코어 기본은 **비활성(null)**이고, 쓰려면 config에 명시하거나 `--handoff <path>`로 준다.
57
+ * (REQ-2026-009: 이전 기본값은 특정 사설 프로젝트의 문서 경로였다.)
58
+ *
59
+ * `handoffPath`의 `as string | null`은 의도적이다. 없으면 TS가 리터럴 `null`로 좁혀
60
+ * `DEFAULTS`를 직접 import하는 소비자의 `string | null` 계약이 깨진다.
61
+ */
52
62
  export const DEFAULTS = {
53
63
  ticketRoot: 'workflow',
54
64
  schemaPath: 'workflow/machine.schema.json',
55
- handoffPath: '../palm-kiosk/docs/evaluation/project-memory/ai-handoff.md',
65
+ handoffPath: null as string | null,
56
66
  branchPrefix: 'feat/req-',
57
67
  packageManager: 'pnpm' as PackageManager,
58
68
  granularityMaxFiles: 8,
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * req:commit — AI REQ 워크플로우 Phase B (REQ-2026-016). 승인된 phase를 커밋하는 래퍼.
4
4
  *
5
- * SSOT 설계: ../palm-kiosk/docs/evaluation/ai-req-workflow-design.md / 본 티켓 01-design.md D-016-3·3b·7·8·9.
5
+ * 설계 근거: 본 티켓 01-design.md D-016-3·3b·7·8·9.
6
6
  * 책임(전체): req:doctor 통과 게이트 → HIGH 사람확인 게이트 → source 커밋(승인 코드만) →
7
7
  * commit_allowed 소비 → evidence-finalize(approvals.jsonl 매니페스트 append + responses chore 커밋) → 2-커밋.
8
8
  * 복구/finalize 모드(pending_evidence_for)·design-finalize 포함.
@@ -2,7 +2,6 @@
2
2
  /**
3
3
  * req:doctor — AI REQ 워크플로우 1차 (단계 4B): 일관성 점검(fail-closed).
4
4
  *
5
- * SSOT: palm-kiosk/docs/evaluation/ai-req-workflow-design.md §8.3.
6
5
  * 1차 최소셋(registry 비의존): D2·D3·D5·D6·D9·D10·D11 + D13(design 선행·freshness)·D15(NEEDS_FIX actionable). (D1/D7/D7b·D4a 등 registry/merge 의존은 2차)
7
6
  * FAIL 1건 이상 → exit 1, 자동 보정 금지(P9). review-codex 헬퍼 재사용.
8
7
  *
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * req:new — AI REQ 워크플로우 1차 (단계 4A): REQ 티켓 + feat/req-* 브랜치 생성.
4
4
  *
5
- * SSOT: palm-kiosk/docs/evaluation/ai-req-workflow-design.md §9.1·§9.2·DEC-WF-020(D11).
5
+ * 설계 근거: DEC-WF-020(D11) — REQ는 main에서 feat/req-* 브랜치로 시작한다.
6
6
  * - state.json은 **BOM 없이** 생성(Node, review-codex의 writeState 재사용).
7
7
  * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
8
  * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
@@ -2,9 +2,7 @@
2
2
  /**
3
3
  * req:review-codex — AI REQ 워크플로우 1차 (단계 2: 조립·바인딩 캡처 + 기반 검증 로직)
4
4
  *
5
- * SSOT 설계: palm-kiosk/docs/evaluation/ai-req-workflow-design.md
6
- * §9.5 호출 문법 · §8.4 staged tree OID 바인딩 · §9.6 구조화 응답·도메인 검증
7
- * 0차 실측: palm-kiosk-app/workflow/00-spike/00-spike-report.md
5
+ * 설계 근거: 호출 문법 · staged tree OID 바인딩 · 구조화 응답·도메인 검증.
8
6
  * 리뷰 반영(Codex, 2단계): schema 버전 필드·STATUS↔COMMIT 모순 검증·state 부재 명확화·Review Context·AJV(단계3)
9
7
  *
10
8
  * 단계 2(완료): 조립(handoff·Review Context·request·staged diff) + git 바인딩(staged tree OID) + dry-run 미리보기,
@@ -17,7 +15,7 @@
17
15
  * 사용:
18
16
  * pnpm req:review-codex <REQ-id> # workflow/REQ-<id>/ 대상
19
17
  * pnpm req:review-codex --ticket <dir> # 임의 티켓 디렉터리
20
- * 옵션: --handoff <path> (기본: ../palm-kiosk/docs/evaluation/project-memory/ai-handoff.md, 없으면 생략)
18
+ * 옵션: --handoff <path> (미지정 시 req.config.json의 handoffPath. 둘 다 없으면 handoff 블록 생략 — 코어 기본은 비활성)
21
19
  */
22
20
  import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'
23
21
  import { resolve, join, relative } from 'node:path'