commitgate 0.3.1 → 0.7.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/bin/init.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  #!/usr/bin/env tsx
2
2
  /**
3
- * commitgate init — AI REQ workflow(커밋 게이트) kit을 대상 git repo에 설치(Stage A / Model A: vendored 스캐폴딩).
3
+ * commitgate init — AI REQ workflow(커밋 게이트) kit을 대상 git repo에 설치(**Stage B: 런타임 패키지 모델**, REQ-2026-014).
4
+ *
5
+ * Stage B의 핵심: **실행 코드와 런타임 의존성은 대상에 복사·주입하지 않는다.** 그것들은 설치된 패키지
6
+ * (`node_modules/commitgate`)에만 있고, 대상에는 `req:* = commitgate <verb>` 스크립트와 **거버넌스·감사 데이터**만 남는다.
7
+ * (Stage A = 과거의 vendored 스캐폴딩 모델. `REQ_SCRIPTS`가 그 서명 기록이고 `commitgate migrate`가 전환을 담당한다.)
4
8
  *
5
9
  * 동작(멱등·비파괴):
6
10
  * 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
+ * 2. **Stage B 전제(순서가 계약)**: `detectStageA`(D19 Stage A 설치본이면 migrate로 보냄)
12
+ * `commitgateDeclared`(D14 `devDependencies.commitgate` 없으면 선행 설치 안내). preflight = 무쓰기 실패.
13
+ * 3. `KIT_COPY_RELPATHS`(스키마 2종 + review-persona.md) 복사(기존 파일은 --force 없으면 스킵).
14
+ * **`scripts/req/**` 복사하지 않는다**(R3패키지에서 실행).
15
+ * 4. `req.config.json` 시드(부재 시): 감지한 packageManager + handoffPath:null(프로젝트별 값은 코어 DEFAULTS가 아니라 config에서 흡수)
16
+ * 5. 대상 `package.json`에 `req:* = commitgate <verb>` 주입(기존 키 미덮어씀). **devDeps는 주입하지 않는다**(R3).
17
+ * 6. `AGENTS.md` 부재 시 템플릿 생성(있으면 스킵 — Codex 계약 보존)
18
+ * 7. 에이전트 진입점(.claude/skills·.claude/commands·.cursor/rules) 복사 + `CLAUDE.md` 부재 시 생성 (--no-agent-entrypoints로 생략)
11
19
  *
12
- * 코어 승인 바인딩·staged tree 검증은 건드리지 않는다(복사만). 프로젝트 차이는 req.config.json에서만 흡수.
20
+ * 코어 승인 바인딩·staged tree 검증은 건드리지 않는다. 프로젝트 차이는 req.config.json에서만 흡수.
13
21
  */
14
22
  import {
15
23
  existsSync,
@@ -18,14 +26,17 @@ import {
18
26
  mkdirSync,
19
27
  readdirSync,
20
28
  statSync,
29
+ lstatSync,
21
30
  copyFileSync,
22
31
  realpathSync,
23
32
  } from 'node:fs'
24
33
  import { execFileSync } from 'node:child_process'
25
- import { resolve, join, dirname, relative } from 'node:path'
34
+ import { createHash } from 'node:crypto'
35
+ import { resolve, join, dirname, relative, isAbsolute } from 'node:path'
26
36
  import { fileURLToPath, pathToFileURL } from 'node:url'
27
- import { loadConfig, stripBom, type PackageManager } from '../scripts/req/lib/config'
37
+ import { loadConfig, stripBom, DEFAULT_REVIEW_PERSONA_RELPATH, type PackageManager } from '../scripts/req/lib/config'
28
38
  import { createGitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
39
+ import { parseStatusZ, entryPaths, STATUS_Z_ARGS, type StatusEntry } from '../scripts/req/lib/porcelain'
29
40
  import * as semver from 'semver'
30
41
 
31
42
  /** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
@@ -41,14 +52,157 @@ export const KIT_SOURCE_DIR_REL = 'scripts/req'
41
52
  */
42
53
  export const KIT_SCHEMA_RELPATHS = ['workflow/machine.schema.json', 'workflow/req.config.schema.json'] as const
43
54
 
44
- /** 대상 package.json에 주입할 req:* 스크립트. */
55
+ /**
56
+ * init이 `scripts/req/**` 외에 **실제로 복사하는** 파일 목록(패키지-상대 = 대상-상대). 복사기와 uninstall planner의 SSOT.
57
+ *
58
+ * ⚠️ `KIT_SCHEMA_RELPATHS`와 **의미가 다르다** — 그 상수는 "설정된 `schemaPath`가 init이 깐 스키마인가"를
59
+ * 판정하는 **스키마 축**이고(`bin/uninstall.ts`), 이 상수는 **복사 축**이다. 여기 persona를 넣되 저기엔 넣지 않는다.
60
+ *
61
+ * ⚠️ `package.json`의 `files[]`는 또 다른 축(npm tarball)이다. **셋을 혼동하면**
62
+ * tarball엔 실렸는데 대상 repo엔 안 깔리는 파일이 생기고, phase-1b의 persona fail-closed와 만나
63
+ * 신규 설치본의 모든 리뷰가 멈춘다(REQ-2026-010 design R1 P1).
64
+ */
65
+ export const KIT_COPY_RELPATHS = [...KIT_SCHEMA_RELPATHS, DEFAULT_REVIEW_PERSONA_RELPATH] as const
66
+
67
+ /**
68
+ * 에이전트 진입점 (REQ-2026-010 D7·D8). init 복사기와 uninstall planner가 공유하는 SSOT.
69
+ *
70
+ * ⚠️ `KIT_COPY_RELPATHS`와 달리 **`src !== dest`**다. `copyInto`는 `relative(PACKAGE_ROOT, src)`로 레이아웃을
71
+ * 재현하므로 쓸 수 없고, `uninstall`의 `tool` 분류도 `join(PACKAGE_ROOT, rel)`로 원본을 찾을 수 없다.
72
+ * 두 곳 모두 `src`/`dest`를 분리해 다뤄야 한다.
73
+ *
74
+ * 본문 SSOT는 `AGENTS.md`다. 여기 깔리는 파일은 **얇은 포인터**이며 계약 본문을 복제하지 않는다 — 복제하면 drift 부채가 된다.
75
+ */
76
+ export const KIT_AGENT_ENTRYPOINTS = [
77
+ { src: 'templates/claude-skill.md', dest: '.claude/skills/commitgate/SKILL.md' },
78
+ { src: 'templates/claude-command.md', dest: '.claude/commands/req.md' },
79
+ { src: 'templates/cursor-rule.mdc', dest: '.cursor/rules/commitgate.mdc' },
80
+ ] as const
81
+
82
+ /** `CLAUDE.md`는 `AGENTS.md`와 같은 취급 — **부재 시에만** 생성하고, uninstall에서 `ambiguous`(자동 제거 금지). */
83
+ export const KIT_CLAUDE_TEMPLATE_REL = 'templates/CLAUDE.template.md'
84
+ export const KIT_CLAUDE_DEST_REL = 'CLAUDE.md'
85
+
86
+ /**
87
+ * `workflow/.gitignore` — 티켓 내부 scratch(codex-response.json 등)를 무시하는 **중첩 .gitignore** (REQ-2026-012).
88
+ *
89
+ * ⚠️ 세 가지가 이 파일을 특별하게 만든다:
90
+ * 1. **`src ≠ dest`**(`KIT_AGENT_ENTRYPOINTS`처럼). npm이 tarball에서 `.gitignore` 이름을 제외하므로
91
+ * 패키지엔 `templates/workflow.gitignore`(비-점 이름)로 두고 `workflow/.gitignore`로 복사한다(설계 D5·D11).
92
+ * 2. `.gitignore`는 git 관례상 **사용자 소유**다 → `AGENTS.md`/`CLAUDE.md`와 동일: **부재 시에만** 생성,
93
+ * `--force`로도 덮지 않는다(설계 D12). `add()`(=`KIT_COPY_RELPATHS`)를 타지 않는다 — 거긴 `--force`가 덮는다.
94
+ * 3. **`--no-agent-entrypoints`와 무관**(설계 D13). 그 옵션은 `.claude/`·`.cursor/`·`CLAUDE.md`만 생략한다.
95
+ */
96
+ export const KIT_GITIGNORE = { src: 'templates/workflow.gitignore', dest: 'workflow/.gitignore' } as const
97
+
98
+ /** `AGENTS.md`가 CommitGate 계약인지 판별하는 마커. 진입점 포인터들이 이 마커로 SSOT를 확인한다. */
99
+ export const AGENTS_CONTRACT_MARKER = '<!-- commitgate:contract -->'
100
+
101
+ /**
102
+ * 기존 `AGENTS.md`에 계약 마커가 없을 때 **대상 repo에 함께 놓는** 계약 템플릿 사본 (phase-3a R1 P2).
103
+ *
104
+ * ⚠️ 이게 없으면 포인터의 복구 지시가 **막다른 길**이 된다. 진입점 파일들은 "마커가 없으면 계약 템플릿을
105
+ * 참조해 병합하라"고 하는데, `AGENTS.template.md`는 **패키지 안에만** 있고 대상 repo에는 복사되지 않는다.
106
+ * `npx commitgate`는 전역/로컬 설치가 아니라 npm 캐시에서 한 번 실행될 뿐이라 `node_modules/commitgate/`도
107
+ * 남지 않는다. 사용자는 참조할 파일을 찾을 수 없다.
108
+ *
109
+ * 그래서 마커가 없을 때만 이 경로로 사본을 놓는다. 마커가 있으면(정상) 이 파일은 만들지 않는다.
110
+ */
111
+ export const KIT_AGENTS_CONTRACT_COPY_REL = 'AGENTS.commitgate.md'
112
+
113
+ /**
114
+ * pm별 lockfile 이름. `detectPackageManager`(아래)와 **같은 축**이다.
115
+ *
116
+ * ⚠️ lockfile을 stage 목록에서 빠뜨리면 설치분을 커밋한 뒤에도 `M pnpm-lock.yaml`이 남아
117
+ * `req:new --run`이 clean-tree 게이트에서 죽는다(REQ-2026-011 design R3 P2).
118
+ *
119
+ * ⚠️ Stage B(REQ-2026-014)에서 근거가 바뀌었다: init은 더 이상 devDeps를 주입하지 않는다. 대신 **선행 `npm i -D commitgate`**
120
+ * 가 `package.json`+lockfile을 이미 바꿔 놓는다(D14가 그 선언을 요구한다). 즉 lockfile은 여전히 설치 커밋에 담겨야 한다 —
121
+ * 갱신 주체가 "init 뒤의 install"에서 "init 앞의 install"로 옮겨졌을 뿐이다.
122
+ */
123
+ export const LOCKFILE: Record<PackageManager, string> = {
124
+ npm: 'package-lock.json',
125
+ pnpm: 'pnpm-lock.yaml',
126
+ yarn: 'yarn.lock',
127
+ }
128
+
129
+ /**
130
+ * **계약 포인터** — 이것들이 git에 추적되지 않으면 설치 목적(팀·CI가 계약을 로드)이 조용히 무너진다.
131
+ * 그래서 gitignore에 걸리면 WARN하고 `--strict`에서 중단한다.
132
+ *
133
+ * lockfile 같은 나머지 산출물은 무시되더라도 정당한 repo 정책일 수 있으므로 경고하지 않고
134
+ * stage 목록에서 조용히 뺀다(REQ-2026-011 DEC-011-10).
135
+ */
136
+ export const CONTRACT_POINTER_RELPATHS: readonly string[] = [
137
+ ...KIT_AGENT_ENTRYPOINTS.map((e) => e.dest),
138
+ 'AGENTS.md',
139
+ KIT_CLAUDE_DEST_REL,
140
+ KIT_AGENTS_CONTRACT_COPY_REL,
141
+ ]
142
+
143
+ /**
144
+ * **Stage A**(vendored scaffold)가 주입하던 req:* 스크립트 값 — 이제 **주입하지 않는다**(REQ-2026-014 R3).
145
+ *
146
+ * 이 상수는 **Stage A 서명 SSOT**로 남는다. 세 소비자가 정확한 바이트 일치를 판정한다:
147
+ * - `detectStageA`(아래 D19) — plain init이 Stage A 프로젝트를 조용히 혼합 설치로 만들지 않게 막는다.
148
+ * - `bin/uninstall.ts`(`REQ_SCRIPTS` 순회, `cur === injected`) — 기존 Stage A 설치본 분류.
149
+ * - `bin/migrate.ts`(Phase 3) — **정확히 이 값일 때만** `commitgate <verb>`로 전환(사용자 정의 값 미덮어씀).
150
+ *
151
+ * ⚠️ 값을 바꾸면 기존 Stage A 설치본을 더 이상 인식하지 못한다. 이것은 "우리가 무엇을 주입하는가"가 아니라
152
+ * **"과거에 무엇을 주입했는가"** 의 기록이다.
153
+ */
45
154
  export const REQ_SCRIPTS: Record<string, string> = {
46
155
  'req:new': 'tsx scripts/req/req-new.ts',
47
156
  'req:review-codex': 'tsx scripts/req/review-codex.ts',
48
157
  'req:doctor': 'tsx scripts/req/req-doctor.ts',
158
+ 'req:next': 'tsx scripts/req/req-next.ts',
49
159
  'req:commit': 'tsx scripts/req/req-commit.ts',
50
160
  }
51
161
 
162
+ /**
163
+ * **Stage B**가 주입하는 req:* 스크립트 값 — 로컬 패키지 bin을 dispatch한다(REQ-2026-014 R1/R2).
164
+ * 키 집합은 `REQ_SCRIPTS`에서 파생해 SSOT를 하나로 유지한다(값만 다르고 키는 같다).
165
+ *
166
+ * `npm run req:new -- <args>` → `commitgate req:new <args>` → `node_modules/.bin/commitgate`
167
+ * → `bin/commitgate.mjs`가 verb를 `scripts/req/req-new.ts`(**패키지 안**)로 dispatch.
168
+ */
169
+ export const STAGE_B_REQ_SCRIPTS: Record<string, string> = Object.fromEntries(
170
+ Object.keys(REQ_SCRIPTS).map((k) => [k, `commitgate ${k}`]),
171
+ )
172
+
173
+ /**
174
+ * **Stage A 서명 감지(D19 — REQ-2026-014 R7)**. 감지된 근거를 반환(없으면 `null`).
175
+ *
176
+ * 왜 필요한가: 스크립트 주입은 `if (!(k in scripts))`라 **기존 값을 덮지 않는다**. 따라서 Stage A 프로젝트에
177
+ * plain init을 돌리면 `req:*`가 vendored `tsx scripts/req/*.ts`인 채로 남아 **런타임은 계속 vendored인데
178
+ * 사용자는 Stage B라 믿는 조용한 혼합 설치**가 된다. 그래서 fail-closed로 막고 `migrate`로 보낸다.
179
+ *
180
+ * 🔴 **호출 순서가 계약이다: 이 검사는 `commitgateDeclared`(D14)보다 반드시 먼저 돌아야 한다.**
181
+ * Stage A 설치본에는 `devDependencies.commitgate`가 **없다** — Stage A는 `npx commitgate`로 설치되고
182
+ * `REQ_DEV_DEPS`는 `ajv`·`cross-spawn`·`tsx`만 주입하지 `commitgate` 자신을 넣지 않는다. 순서를 뒤집으면
183
+ * Stage A 사용자는 **항상 D14에서 먼저 죽어** "npm install -D commitgate"라는 엉뚱한 안내를 받고
184
+ * `commitgate migrate` 안내에 **영원히 도달하지 못한다**(design r20 P1). 회귀 테스트가 이 순서를 고정한다.
185
+ */
186
+ export function detectStageA(targetRoot: string, scripts: Record<string, string>): string | null {
187
+ for (const [k, injected] of Object.entries(REQ_SCRIPTS)) if (scripts[k] === injected) return `package.json#scripts.${k}`
188
+ if (existsSync(join(targetRoot, KIT_SOURCE_DIR_REL))) return `${KIT_SOURCE_DIR_REL}/`
189
+ return null
190
+ }
191
+
192
+ /**
193
+ * **선행 설치 확인(D14, 축소 — REQ-2026-014 R6)**. `devDependencies.commitgate` **키 존재만** 본다.
194
+ *
195
+ * 🔴 **값의 형태를 검증하지 않는다.** `npm install -D <packed tarball>`은 `"commitgate": "file:../x.tgz"`를 쓴다 —
196
+ * semver range가 아니다. `link:`·`workspace:`·git URL도 정당한 설치 형태다. 값을 range로 검증하면
197
+ * packed-tarball smoke가 **스스로 실패**한다.
198
+ *
199
+ * 범위 밖(REQ-2026-014 §4 비목표): `node_modules/commitgate` 존재 확인·실행 패키지 realpath 동일성·
200
+ * lockfile 해결 버전 대조. 설치 **완료** 보장은 package manager의 책임이고, 여기 계약은 "설치 의도가 선언됐는가"다.
201
+ */
202
+ export function commitgateDeclared(devDeps: Record<string, string>): boolean {
203
+ return Object.prototype.hasOwnProperty.call(devDeps, 'commitgate')
204
+ }
205
+
52
206
  /** cross-spawn 주입 spec(= 보안 하한 SSOT). 진단(#1)과 주입이 이 값을 공유. */
53
207
  const CROSS_SPAWN_SPEC = '^7.0.6'
54
208
 
@@ -64,12 +218,41 @@ export interface InitOptions {
64
218
  force: boolean
65
219
  dryRun: boolean
66
220
  strict: boolean // cross-spawn 하한 미만이면 WARN 대신 throw(#1)
221
+ /** `.claude/`·`.cursor/`·`CLAUDE.md`를 건너뛴다. 다른 도구가 그 디렉터리를 쓰는 repo를 위한 opt-out(D7). */
222
+ noAgentEntrypoints?: boolean
223
+ }
224
+
225
+ /**
226
+ * 설치 **전** 워킹트리 상태 3분류(DEC-011-11). 쓰기 전에 찍어야 CommitGate 산출물과 섞이지 않는다.
227
+ *
228
+ * - `staged`: 인덱스 ≠ HEAD. `git commit`은 인덱스 **전체**를 담으므로, 안내가 `git add`를 아무리
229
+ * 명시해도 이 변경들이 설치 커밋에 함께 들어간다.
230
+ * - `overlapping`: 설치 산출물과 겹치는 **tracked·unstaged** 변경. init이 같은 파일을 수정하므로
231
+ * 사용자 변경과 설치 변경을 사후 분리할 수 없다(예: 이미 고쳐 둔 `package.json`).
232
+ * - `unrelated`: 나머지 dirty. 인덱스에 없으므로 설치 커밋에 섞이지 않는다 — 커밋 뒤 `git stash -u`로 치우면 된다.
233
+ *
234
+ * untracked 산출물(`?? package.json` 등)은 어디에도 담지 않는다. 파일 전체가 신규라 분리할 것이 없다.
235
+ */
236
+ export interface PreexistingDirty {
237
+ staged: string[]
238
+ overlapping: string[]
239
+ unrelated: string[]
67
240
  }
68
241
 
69
242
  export interface InitResult {
70
243
  targetRoot: string
71
244
  copied: string[] // repo-상대 경로(신규 복사)
72
245
  skipped: string[] // repo-상대 경로(이미 존재 → 미덮어씀)
246
+ /** init이 만들거나 수정하는 repo-상대 경로 전수. ignore 검사와 설치 후 stage 목록이 **공유**하는 SSOT. */
247
+ artifacts: string[]
248
+ /** `artifacts` 중 gitignore에 걸리고 **untracked**인 것(= `git add`가 fatal인 것). */
249
+ gitIgnoredArtifacts: string[]
250
+ /** devDeps를 주입해 `<pm> install`이 갱신할 lockfile 경로. 주입이 없으면 null. */
251
+ lockfileRel: string | null
252
+ /** `node_modules`가 ignore도 track도 되지 않아 `<pm> install` 후 워킹트리를 dirty하게 만드는가. */
253
+ nodeModulesWillDirty: boolean
254
+ /** 설치 **전** 워킹트리 상태(쓰기 전 스냅샷). */
255
+ preexistingDirty: PreexistingDirty
73
256
  configAction: 'created' | 'merged' | 'unchanged' // req.config.json: 신규 생성 / 누락키 병합 / 변경 없음
74
257
  configKeysAdded: string[] // 병합 시 추가된 키(handoffPath·packageManager)
75
258
  packageJsonAdded: string[] // 추가된 script/devDep 키
@@ -77,6 +260,15 @@ export interface InitResult {
77
260
  packageManager: PackageManager
78
261
  crossSpawnFloorWarned: boolean // 기존 cross-spawn이 보안 하한 미만이라 경고(#1)
79
262
  dryRun: boolean
263
+ claudeMdCreated: boolean // CLAUDE.md를 새로 만들었는가(있으면 미덮어씀)
264
+ agentsMarkerMissing: boolean // 기존 AGENTS.md에 commitgate 계약 마커가 없어 경고했는가
265
+ agentsContractCopyCreated: boolean // 마커 부재 시 AGENTS.commitgate.md(계약 템플릿 사본)를 놓았는가
266
+ agentEntrypointsSkipped: boolean // --no-agent-entrypoints
267
+ workflowGitignoreCreated: boolean // workflow/.gitignore를 새로 만들었는가(있으면 보존, --force로도 미덮어씀)
268
+ /** 기존 workflow/.gitignore가 kit과 달라 보존했는가(사용자 정책 파일). stash 안내에서 제외 대상(phase-2 리뷰 P4). */
269
+ workflowGitignoreUserDiffers: boolean
270
+ /** workflow/.gitignore가 ignored∧untracked라 설치 커밋에 못 담기고 fresh clone에 scratch 정책이 없다(phase-2 리뷰 P2). */
271
+ workflowGitignorePolicyAtRisk: boolean
80
272
  }
81
273
 
82
274
  /**
@@ -109,6 +301,192 @@ export function assertGitWorkTree(targetRoot: string, run?: GitRunner): void {
109
301
  throw new Error(`대상이 git repo 최상위가 아님: ${targetRoot} (top-level=${topLevel}) — repo 루트에서 실행.`)
110
302
  }
111
303
 
304
+ /**
305
+ * `git status --porcelain` 한 줄을 `{index, worktree, path}`로 분해. rename(`R old -> new`)은 새 경로를 쓴다.
306
+ * 파싱 불가면 null(무시) — 진단 목적이라 fail-closed로 만들 이유가 없다.
307
+ */
308
+ // unquoteGitPath·parsePorcelainLine은 삭제(REQ-2026-012). `-z`는 인용을 하지 않으므로 되돌릴 게 없다 —
309
+ // 파싱은 lib/porcelain의 parseStatusZ 하나로 통일한다.
310
+
311
+ /**
312
+ * 설치 전 워킹트리를 3분류(DEC-011-11). **순수 함수** — status 엔트리와 산출물 목록만 받는다.
313
+ * 입력은 `parseStatusZ`(= `git status --porcelain=v1 -z --untracked-files=all`)의 산출.
314
+ */
315
+ export function classifyPreexistingDirty(entries: readonly StatusEntry[], artifacts: readonly string[]): PreexistingDirty {
316
+ const artifactSet = new Set(artifacts)
317
+ const out: PreexistingDirty = { staged: [], overlapping: [], unrelated: [] }
318
+ for (const e of entries) {
319
+ // 인덱스에 올라간 변경(`?`는 untracked 표식이라 staged가 아니다) → 커밋이 삼킨다.
320
+ if (e.index !== ' ' && e.index !== '?') {
321
+ out.staged.push(e.path)
322
+ continue
323
+ }
324
+ if (artifactSet.has(e.path)) {
325
+ // tracked + unstaged 수정만 문제다. untracked 산출물은 파일 전체가 신규라 분리할 것이 없다.
326
+ if (e.index === ' ' && e.worktree !== ' ' && e.worktree !== '?') out.overlapping.push(e.path)
327
+ continue
328
+ }
329
+ out.unrelated.push(e.path)
330
+ }
331
+ return out
332
+ }
333
+
334
+ /**
335
+ * `paths` 중 **gitignore에 걸리고 untracked인** 것(= `git add <path>`가 fatal인 것)만 반환한다.
336
+ *
337
+ * ⚠️ `git check-ignore`만으로 판정하면 안 된다(DEC-011-10). 그 명령은 **인덱스를 보지 않으므로**,
338
+ * ignore 규칙에 걸리지만 이미 tracked인 파일(강제 add된 lockfile 등)까지 "무시됨"으로 보고한다.
339
+ * 그런 파일은 `git add`가 정상 동작하므로 제외 대상이 아니다.
340
+ *
341
+ * exit 코드: `0`=무시됨, `1`=무시 안 됨, `128`=오류. **128은 "무시 안 됨"으로 취급**한다 —
342
+ * git 버전차·비정상 상태 때문에 설치를 막는 오탐을 만들지 않는다.
343
+ * 파일이 없어도 규칙 매칭이므로 **쓰기 전에** 판정할 수 있다(preflight 배치의 전제).
344
+ */
345
+ function gitIsIgnored(targetRoot: string, p: string): boolean {
346
+ try {
347
+ execFileSync('git', ['check-ignore', '-q', '--', p], { cwd: targetRoot, stdio: 'ignore' })
348
+ return true
349
+ } catch {
350
+ return false
351
+ }
352
+ }
353
+
354
+ function gitIsTracked(targetRoot: string, p: string): boolean {
355
+ try {
356
+ const out = execFileSync('git', ['ls-files', '--', p], {
357
+ cwd: targetRoot,
358
+ encoding: 'utf8',
359
+ stdio: ['ignore', 'pipe', 'ignore'],
360
+ })
361
+ return out.trim().length > 0
362
+ } catch {
363
+ return false
364
+ }
365
+ }
366
+
367
+ export function findIgnoredArtifacts(targetRoot: string, paths: readonly string[]): string[] {
368
+ return paths.filter((p) => gitIsIgnored(targetRoot, p) && !gitIsTracked(targetRoot, p))
369
+ }
370
+
371
+ /**
372
+ * `destRel`의 **모든 상위 컴포넌트**를 `lstat`으로 검사해 symlink·비-디렉터리를 거부한다(phase-2 리뷰 P1).
373
+ *
374
+ * ⚠️ `realpath`는 링크를 **따라가므로** 저장소 **내부**를 가리키는 symlink를 통과시키고(git이 그 아래를
375
+ * 정상 stage하지 못한다), dangling symlink는 ENOENT라 "부재"로 오인한다. 그래서 컴포넌트별 `lstat`으로:
376
+ * - 실제 부재(ENOENT) → 그 하위는 targetRoot 안에 새로 생성됨(안전) → 통과.
377
+ * - symlink/junction → 목적지(내부·외부·dangling) **무관하게 거부**. `copyFileSync`가 밖에 쓰거나 git이 못 읽는다.
378
+ * - 디렉터리 아님(파일·특수) → 하위를 만들 수 없다 → 거부.
379
+ * - 그 밖의 오류(EACCES 등) → fail-closed throw.
380
+ * `workflow/.gitignore`로 preflight에서 돌리면 같은 `workflow/`를 쓰는 스키마 복사도 함께 보호된다.
381
+ */
382
+ function assertConfinedDest(targetRoot: string, destRel: string): void {
383
+ const segs = destRel.split('/')
384
+ let cur = targetRoot
385
+ for (let i = 0; i < segs.length - 1; i++) {
386
+ // 마지막(파일명) 제외한 상위 컴포넌트만
387
+ cur = join(cur, segs[i] as string)
388
+ let st: ReturnType<typeof lstatSync>
389
+ try {
390
+ st = lstatSync(cur)
391
+ } catch (e) {
392
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return // 실제 부재 → 하위를 targetRoot 안에 새로 만든다(안전)
393
+ throw new Error(`${destRel} 상위 경로 확인 실패(${(e as Error).message}) — fail-closed.`)
394
+ }
395
+ if (st.isSymbolicLink())
396
+ throw new Error(`${destRel} 의 상위 '${segs[i]}' 가 symlink/junction 입니다 — confinement 위반(밖에 쓰거나 git이 못 읽음).`)
397
+ if (!st.isDirectory()) throw new Error(`${destRel} 의 상위 '${segs[i]}' 가 디렉터리가 아닙니다 — confinement 위반.`)
398
+ }
399
+ }
400
+
401
+ /**
402
+ * `<pm> install`이 만드는 `node_modules/`가 **워킹트리를 dirty하게 만드는가**.
403
+ * ignore되지도 tracked되지도 않으면 `?? node_modules/`로 나타나 `req:new --run`의 clean-tree 게이트를 막는다.
404
+ *
405
+ * README가 지시하는 `git init && npm init -y`에는 `.gitignore`가 없다 — 그 경로는 **100% 재현된다.**
406
+ * 안내가 이 사실을 짚어 주지 않으면 사용자는 설치분을 다 커밋하고도 첫 명령에서 막힌다(실측).
407
+ */
408
+ /**
409
+ * `node_modules/`를 무시하는 규칙이 **저장소에 커밋되는 `.gitignore`**에서 왔는가.
410
+ *
411
+ * ⚠️ `git check-ignore`는 `.git/info/exclude`와 전역 `core.excludesFile`도 인정한다. 그 둘은
412
+ * **clone에 따라오지 않는다.** 설치한 사람의 로컬 설정 때문에 `nodeModulesWillDirty=false`가 되면,
413
+ * 팀원의 fresh clone에서 `<pm> install` 후 `?? node_modules/`가 나타나 `req:new --run`이 막힌다.
414
+ * 설치 결과는 **저장소에 이식 가능**해야 한다(phase-6 리뷰 R4).
415
+ *
416
+ * `check-ignore -v` 출력은 `<source>:<line>:<pattern>\t<pathname>`이다. source가 repo 내부의
417
+ * `.gitignore`(상대경로)일 때만 인정한다 — `.git/info/exclude`는 basename이 다르고, 전역 파일은 절대경로다.
418
+ *
419
+ * ⚠️ 조회 경로에 후행 슬래시가 필요하다. 가장 흔한 패턴 `node_modules/`는 **디렉터리 전용**이라
420
+ * `node_modules`(슬래시 없음)로는 매칭되지 않는다 — 경로가 없으면 git이 디렉터리인지 알 수 없기 때문.
421
+ */
422
+ function nodeModulesIgnoredByRepoGitignore(targetRoot: string): boolean {
423
+ try {
424
+ const out = execFileSync('git', ['check-ignore', '-v', '--', 'node_modules/'], {
425
+ cwd: targetRoot,
426
+ encoding: 'utf8',
427
+ stdio: ['ignore', 'pipe', 'ignore'],
428
+ })
429
+ const source = (out.split('\t')[0] ?? '').split(':')[0]?.replace(/\\/g, '/') ?? ''
430
+ if (source === '' || isAbsolute(source) || source.startsWith('.git/')) return false
431
+ if (source !== '.gitignore' && !source.endsWith('/.gitignore')) return false
432
+ // ⚠️ 파일이 있는 것만으론 부족하다 — **tracked**여야 clone에 따라온다.
433
+ // `.gitignore`가 아직 커밋되지 않았거나(혹은 `.git/info/exclude`로 자신이 숨겨져 있어도)
434
+ // 그 규칙은 팀원의 fresh clone에 없다(phase-6 리뷰 R6).
435
+ return gitIsTracked(targetRoot, source)
436
+ } catch {
437
+ return false // exit 1 = 무시 안 됨, 128 = 오류(오탐 방지 위해 "무시 안 됨"으로)
438
+ }
439
+ }
440
+
441
+ function nodeModulesWillDirty(targetRoot: string): boolean {
442
+ return !nodeModulesIgnoredByRepoGitignore(targetRoot) && !gitIsTracked(targetRoot, 'node_modules/')
443
+ }
444
+
445
+ /**
446
+ * `.gitignore`가 설치 커밋에 합류해야 하는가 (phase-6 리뷰 R1·R2).
447
+ *
448
+ * 두 경우다:
449
+ * 1. `node_modules`가 아직 무시되지 않는다 → 사용자가 규칙을 추가해야 하고, 그 수정은 설치 커밋에 담긴다.
450
+ * 2. **`.gitignore`가 dirty하다** → `node_modules`가 이미 무시되더라도 그 규칙이 **커밋되지 않은**
451
+ * `.gitignore`에서 왔을 수 있다. 안내가 그 파일을 `git stash push -u`로 치우는 순간 규칙이 사라져
452
+ * `?? node_modules/`가 되살아나고 clean-tree 게이트가 다시 깨진다.
453
+ *
454
+ * 산출물로 편입하면 나머지는 기존 3분류가 처리한다 — untracked면 무해(그대로 stage), tracked·unstaged면
455
+ * overlapping(안내를 내지 않음). dirty `.gitignore`에 무관한 수정만 있는 경우도 보수적으로 막히지만,
456
+ * `package.json`이 dirty할 때와 같은 정책이라 일관된다: **잘못된 안내보다 안내 없음이 낫다.**
457
+ */
458
+ function gitignoreJoinsInstall(nodeModulesDirty: boolean, entries: readonly StatusEntry[]): boolean {
459
+ if (nodeModulesDirty) return true
460
+ // rename의 src·dest 어느 쪽이 `.gitignore`여도 그 파일은 dirty다(entryPaths로 둘 다 본다).
461
+ return entries.some((e) => entryPaths(e).includes('.gitignore'))
462
+ }
463
+
464
+ function sha256File(abs: string): string {
465
+ return createHash('sha256').update(readFileSync(abs)).digest('hex')
466
+ }
467
+
468
+ /** kit `workflow/.gitignore` 템플릿의 규칙 라인(주석·빈 줄 제외). differs WARN에서 사용자가 병합할 실제 규칙을 보여 준다. */
469
+ function kitGitignoreRules(): string[] {
470
+ return readFileSync(join(PACKAGE_ROOT, KIT_GITIGNORE.src), 'utf8')
471
+ .split('\n')
472
+ .map((l) => l.trim())
473
+ .filter((l) => l !== '' && !l.startsWith('#'))
474
+ }
475
+
476
+ /** 설치 전 워킹트리 상태(쓰기 전). git 실패 시 빈 목록 — 진단이지 게이트가 아니다. */
477
+ function gitStatusEntries(targetRoot: string): StatusEntry[] {
478
+ try {
479
+ const raw = execFileSync('git', [...STATUS_Z_ARGS], {
480
+ cwd: targetRoot,
481
+ encoding: 'utf8',
482
+ stdio: ['ignore', 'pipe', 'ignore'],
483
+ })
484
+ return parseStatusZ(raw)
485
+ } catch {
486
+ return []
487
+ }
488
+ }
489
+
112
490
  /** lockfile로 대상 패키지매니저 감지(없으면 npm — 가장 보편적 기본). */
113
491
  export function detectPackageManager(root: string): PackageManager {
114
492
  if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm'
@@ -139,31 +517,192 @@ function walkFiles(dir: string): string[] {
139
517
  }
140
518
 
141
519
  /**
142
- * srcAbs 파일들을 targetRoot 하위 동일 상대경로로 복사. 기존 파일은 force 없으면 스킵.
143
- * srcBase는 상대경로 계산 기준(= PACKAGE_ROOT)이라 대상에서도 같은 레이아웃 유지.
520
+ * init이 만들거나 수정할 것의 **쓰기 계획**(REQ-2026-011 DEC-011-9).
521
+ *
522
+ * ⚠️ 왜 `InitResult`가 아니라 별도 계획인가: `InitResult.copied`는 Apply 단계에서 채워진다.
523
+ * preflight의 gitignore 검사가 그것을 볼 수 없고, 검사를 복사 뒤로 옮기면 `--strict`가
524
+ * `scripts/req/**`·`req.config.json`·`package.json`을 이미 쓴 뒤에 throw하게 되어
525
+ * "파일을 하나도 쓰지 않고 throw" 계약이 깨진다(design 리뷰 R4).
526
+ *
527
+ * preflight(ignore 검사)·apply(복사)·설치 후 안내(stage 목록) **셋이 같은 계획을 읽는다.**
528
+ * 목록을 따로 관리하면 어긋난다 — R2에서 `AGENTS.commitgate.md`가, R3에서 lockfile이 빠졌다.
144
529
  */
145
- function copyInto(
146
- srcFiles: string[],
147
- srcBase: string,
148
- targetRoot: string,
149
- opts: InitOptions,
150
- copied: string[],
151
- skipped: string[],
152
- ): void {
153
- for (const srcAbs of srcFiles) {
154
- const rel = relative(srcBase, srcAbs).replace(/\\/g, '/')
155
- const destAbs = join(targetRoot, rel)
156
- if (existsSync(destAbs) && !opts.force) {
157
- skipped.push(rel)
158
- continue
530
+ export interface InstallPlan {
531
+ copies: { srcAbs: string; destRel: string }[]
532
+ skips: string[]
533
+ /**
534
+ * `skips` 중 **패키지 원본과 바이트가 같은** 것 = CommitGate가 소유한다고 확인된 파일
535
+ * (커밋 전에 init을 두 번 돌렸을 때 생긴다). 안내의 stage 목록에는 이것만 포함한다.
536
+ *
537
+ * ⚠️ `skips` 전체를 산출물로 넣으면, init이 **보존하려던 사용자 파일**(예: 원래 있던
538
+ * `.cursor/rules/commitgate.mdc`)을 설치 커밋에 담게 되어 `git add -A` 금지의 목적을 우회한다
539
+ * (phase-6 리뷰 R3). 소유권 판정은 `bin/uninstall.ts`의 sha256 비교와 같은 축이다.
540
+ */
541
+ ownedSkips: string[]
542
+ configRel: string | null // req.config.json — 생성·병합 시
543
+ packageJsonRel: string | null // package.json — 주입 시
544
+ lockfileRel: string | null // LOCKFILE[pm] — 주입 시(install이 갱신)
545
+ agentsRel: string | null // AGENTS.md — 부재였을 때
546
+ claudeMdRel: string | null // CLAUDE.md — 부재였을 때
547
+ contractCopyRel: string | null // AGENTS.commitgate.md — 기존 AGENTS.md에 마커 없을 때
548
+ /**
549
+ * `.gitignore` — init이 쓰지는 않지만, `node_modules`가 무시되지 않아 **사용자가 고치도록 안내하고
550
+ * 설치 커밋에 함께 담을** 때만 산출물이 된다.
551
+ *
552
+ * ⚠️ 산출물에 넣어야 `classifyPreexistingDirty`가 이 파일을 본다. 그러지 않으면 tracked `.gitignore`에
553
+ * 이미 있던 unstaged 수정이 안내의 `git add`에 딸려 들어가 설치 커밋을 오염시킨다 — `git add -A`를
554
+ * 금지한 이유(DEC-011-7)를 정확히 우회하게 된다(phase-6 리뷰 R1).
555
+ */
556
+ gitignoreRel: string | null
557
+ /** `workflow/.gitignore` — 부재라 새로 생성할 때(설계 D4). AGENTS.md 모델: 생성 시에만 산출물. */
558
+ workflowGitignoreRel: string | null
559
+ }
560
+
561
+ /** `planInstall`이 preflight에서 이미 계산해 둔 사실들(중복 계산 방지). */
562
+ export interface PlanFacts {
563
+ configWillWrite: boolean
564
+ packageJsonWillWrite: boolean
565
+ agentsWillCreate: boolean
566
+ claudeMdWillCreate: boolean
567
+ contractCopyWillCreate: boolean
568
+ agentEntrypointsSkipped: boolean
569
+ /** `node_modules`가 워킹트리를 dirty하게 만들어 (루트) `.gitignore` 수정을 안내해야 하는가. */
570
+ gitignoreWillJoin: boolean
571
+ /** `workflow/.gitignore`(kit 파일)가 부재라 새로 생성하는가. `--no-agent-entrypoints`와 무관(D13). */
572
+ workflowGitignoreWillCreate: boolean
573
+ /** 기존 `workflow/.gitignore`가 템플릿과 바이트 동일(= 직전 실행이 깐 것). ownedSkips에 직접 편입(D4 축). */
574
+ workflowGitignoreOwnedSkip: boolean
575
+ }
576
+
577
+ /**
578
+ * 계획이 만들거나 수정할 repo-상대 경로 전수. ignore 검사와 stage 목록이 공유한다.
579
+ *
580
+ * `skips`(이미 존재해 덮어쓰지 않는 kit 파일)도 포함한다. init은 **멱등**하므로 커밋 전에 두 번
581
+ * 실행될 수 있는데, 그때 skip된 kit 파일이 산출물에서 빠지면 `unrelated`로 분류되어 안내가
582
+ * "stash 하십시오"라고 말한다 — 방금 깐 kit을 치우라는 뜻이 된다. 안내도 멱등해야 한다.
583
+ */
584
+ export function planArtifactPaths(plan: InstallPlan): string[] {
585
+ const extras = [
586
+ plan.configRel,
587
+ plan.packageJsonRel,
588
+ plan.lockfileRel,
589
+ plan.agentsRel,
590
+ plan.claudeMdRel,
591
+ plan.contractCopyRel,
592
+ plan.gitignoreRel,
593
+ plan.workflowGitignoreRel,
594
+ ]
595
+ return [...plan.copies.map((c) => c.destRel), ...plan.ownedSkips, ...extras.filter((p): p is string => p !== null)]
596
+ }
597
+
598
+ /**
599
+ * 복사 계획 수립(IO는 `existsSync`/`readdirSync`만 — 쓰기 없음). 기존 파일은 `force` 없으면 스킵.
600
+ *
601
+ * `scripts/req/**`와 `KIT_COPY_RELPATHS`는 **패키지-상대 = 대상-상대**(리터럴 `workflow/`).
602
+ * 진입점은 `src !== dest`라 명시적 매핑으로 다룬다.
603
+ */
604
+ export function planInstall(targetRoot: string, force: boolean, pm: PackageManager, facts: PlanFacts): InstallPlan {
605
+ const copies: { srcAbs: string; destRel: string }[] = []
606
+ const skips: string[] = []
607
+ const ownedSkips: string[] = []
608
+ const sha = (p: string): string | null => {
609
+ try {
610
+ return createHash('sha256').update(readFileSync(p)).digest('hex')
611
+ } catch {
612
+ return null
159
613
  }
160
- copied.push(rel)
161
- if (opts.dryRun) continue
614
+ }
615
+ const add = (srcAbs: string, destRel: string): void => {
616
+ const destAbs = join(targetRoot, destRel)
617
+ if (existsSync(destAbs) && !force) {
618
+ skips.push(destRel)
619
+ // 바이트가 같으면 CommitGate 소유(직전 실행이 깐 것). 다르면 사용자 파일 — 설치 커밋에 담지 않는다.
620
+ const a = sha(destAbs)
621
+ if (a !== null && a === sha(srcAbs)) ownedSkips.push(destRel)
622
+ return
623
+ }
624
+ copies.push({ srcAbs, destRel })
625
+ }
626
+ // ⚠️ Stage B(REQ-2026-014 R3): `scripts/req/**` 를 대상에 **복사하지 않는다**. 실행 코드는 패키지
627
+ // (`node_modules/commitgate/scripts/req/**`)에만 있고, `req:* = commitgate <verb>` 가 그리로 dispatch한다.
628
+ // `KIT_SOURCE_DIR_REL` 상수는 그대로 남는다 — `detectStageA`(D19)와 `bin/uninstall.ts`(기존 Stage A 설치본 분류)가 쓴다.
629
+ // ⚠️ `package.json` files[]의 `scripts/req` 항목은 **유지해야 한다** — 패키지 자신의 bin이 그리로 dispatch한다.
630
+ // 복사 축(여기)과 tarball 축(files[])은 서로 다른 축이다.
631
+ for (const rel of KIT_COPY_RELPATHS) add(join(PACKAGE_ROOT, rel), rel)
632
+ if (!facts.agentEntrypointsSkipped)
633
+ for (const { src, dest } of KIT_AGENT_ENTRYPOINTS) add(join(PACKAGE_ROOT, src), dest)
634
+ // workflow/.gitignore는 add()를 타지 않는다(D12: --force가 사용자 파일을 덮으면 안 됨). 바이트 동일 재실행분은
635
+ // 여기서 ownedSkips에 **직접** 편입 — 그러지 않으면 stageList에서 빠져 unrelated로 오분류된다(phase-2 리뷰 R1).
636
+ if (facts.workflowGitignoreOwnedSkip) ownedSkips.push(KIT_GITIGNORE.dest)
637
+
638
+ return {
639
+ copies,
640
+ skips,
641
+ ownedSkips,
642
+ configRel: facts.configWillWrite ? 'req.config.json' : null,
643
+ packageJsonRel: facts.packageJsonWillWrite ? 'package.json' : null,
644
+ // devDeps를 주입했으면 `<pm> install`이 lockfile을 갱신한다. 안내가 이것을 stage해야 clean-tree가 성립한다.
645
+ lockfileRel: facts.packageJsonWillWrite ? LOCKFILE[pm] : null,
646
+ agentsRel: facts.agentsWillCreate ? 'AGENTS.md' : null,
647
+ claudeMdRel: facts.claudeMdWillCreate ? KIT_CLAUDE_DEST_REL : null,
648
+ contractCopyRel: facts.contractCopyWillCreate ? KIT_AGENTS_CONTRACT_COPY_REL : null,
649
+ gitignoreRel: facts.gitignoreWillJoin ? '.gitignore' : null,
650
+ // D13: --no-agent-entrypoints와 무관하게 산출물에 편입 → stageList가 설치 커밋에 담아 팀·CI에 전파.
651
+ workflowGitignoreRel: facts.workflowGitignoreWillCreate ? KIT_GITIGNORE.dest : null,
652
+ }
653
+ }
654
+
655
+ /**
656
+ * 진입점 dest 경로들이 **실제로 만들어질 수 있는지** preflight에서 확인한다 (D8).
657
+ *
658
+ * `mkdirSync(recursive)`는 경로 중간 컴포넌트가 **파일**이면 ENOTDIR로 죽는다. apply 단계에서 그러면
659
+ * 앞의 파일들은 이미 복사된 뒤라 **부분 설치**가 된다. 쓰기 전에 걸러서 preflight→apply 계약을 지킨다.
660
+ */
661
+ function assertEntrypointPathsUsable(targetRoot: string): void {
662
+ const dests = [...KIT_AGENT_ENTRYPOINTS.map((e) => e.dest), KIT_CLAUDE_DEST_REL, KIT_AGENTS_CONTRACT_COPY_REL]
663
+ for (const dest of dests) {
664
+ const parts = dest.split('/')
665
+ // 마지막(파일명) 제외한 각 디렉터리 컴포넌트가 파일로 존재하면 mkdir 불가.
666
+ for (let i = 0; i < parts.length - 1; i++) {
667
+ const sub = join(targetRoot, ...parts.slice(0, i + 1))
668
+ if (existsSync(sub) && !statSync(sub).isDirectory())
669
+ throw new Error(
670
+ `진입점 설치 불가: ${parts.slice(0, i + 1).join('/')} 가 디렉터리가 아니라 파일입니다(${dest} 를 만들 수 없음).\n` +
671
+ ` → 해당 파일을 옮기거나, --no-agent-entrypoints 로 이 계층을 건너뛰세요.`,
672
+ )
673
+ }
674
+ const abs = join(targetRoot, dest)
675
+ if (existsSync(abs) && statSync(abs).isDirectory())
676
+ throw new Error(`진입점 설치 불가: ${dest} 가 디렉터리로 존재합니다(파일이어야 함).`)
677
+ }
678
+ }
679
+
680
+ /** 계획대로 복사(중첩 디렉터리 생성). `--dry-run`이면 아무것도 쓰지 않는다. */
681
+ function applyCopies(targetRoot: string, plan: InstallPlan): void {
682
+ for (const { srcAbs, destRel } of plan.copies) {
683
+ const destAbs = join(targetRoot, destRel)
162
684
  mkdirSync(dirname(destAbs), { recursive: true })
163
685
  copyFileSync(srcAbs, destAbs)
164
686
  }
165
687
  }
166
688
 
689
+ /**
690
+ * gitignore된 계약 포인터에 대한 경고 문구. **동작하는 패턴을 제시**해야 한다.
691
+ *
692
+ * git 공식 문서(`gitignore(5)`): *"It is not possible to re-include a file if a parent directory of
693
+ * that file is excluded."* 그래서 `.claude` + `!.claude/skills/**`는 **동작하지 않는다.**
694
+ * 이 함정을 알려 주지 않으면 사용자는 고쳤다고 믿으면서 여전히 추적되지 않는다.
695
+ */
696
+ function ignoredPointerMessage(ignored: readonly string[]): string {
697
+ return (
698
+ `다음 계약 포인터가 .gitignore로 무시됩니다 — 팀·CI의 fresh clone에 공유되지 않습니다:\n` +
699
+ ignored.map((p) => ` ${p}`).join('\n') +
700
+ `\n git은 부모 디렉터리가 제외되면 하위 부정 패턴을 무시합니다(gitignore(5)).\n` +
701
+ ` \`.claude\` 대신 아래처럼 바꾸면 설정 파일은 계속 무시하면서 진입점만 추적할 수 있습니다:\n\n` +
702
+ ` .claude/*\n !.claude/skills/\n !.claude/skills/**\n !.claude/commands/\n !.claude/commands/**`
703
+ )
704
+ }
705
+
167
706
  /** JSON 파일을 객체로 파싱(fail-closed). 파싱 실패·비-객체(배열/원시)면 throw. */
168
707
  function parseJsonObject(path: string, label: string): Record<string, unknown> {
169
708
  let parsed: unknown
@@ -290,7 +829,26 @@ export function runInit(opts: InitOptions): InitResult {
290
829
  throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — 배열/원시 미지원.`)
291
830
  }
292
831
 
832
+ // ══ Stage B 전제(REQ-2026-014). 순서가 계약이다: D19(Stage A 서명) → D14(선행 설치) ══
833
+ // 뒤집으면 Stage A 사용자가 D14에서 먼저 죽어 migrate 안내에 도달하지 못한다 — `detectStageA` 주석 참조(design r20 P1).
834
+ // 둘 다 preflight라 throw 시 어떤 파일도 쓰이지 않는다.
835
+ const stageASignature = detectStageA(targetRoot, pkg.scripts ?? {})
836
+ if (stageASignature !== null)
837
+ throw new Error(
838
+ `이미 Stage A(vendored) 설치본입니다 — 감지: ${stageASignature}. ` +
839
+ `plain init은 기존 req:* 를 덮지 않아 vendored 런타임이 계속 실행되는 혼합 설치가 됩니다. ` +
840
+ `'commitgate migrate' 로 전환하세요(기본 dry-run — 아무것도 삭제하지 않습니다).`,
841
+ )
842
+ if (!commitgateDeclared(pkg.devDependencies ?? {}))
843
+ throw new Error(
844
+ `devDependencies.commitgate 선언이 없습니다 — Stage B는 req:* 를 'commitgate <verb>' 로 심으므로 ` +
845
+ `대상에 commitgate가 devDependency로 있어야 합니다. 먼저 'npm install -D commitgate' 를 실행한 뒤 'commitgate init' 하세요.`,
846
+ )
847
+
293
848
  // cross-spawn 보안 하한 진단(#1): 기존 cross-spawn이 하한 미만이면 WARN(기본)/throw(--strict). preflight라 strict throw 시 부분 설치 없음.
849
+ // ⚠️ Stage B는 대상의 cross-spawn을 **실행하지 않는다**(safeSpawnSync는 패키지 자신의 dependencies.cross-spawn에서 돈다).
850
+ // 대상에 cross-spawn이 없으면 자동 무동작(existingCrossSpawnSpec→null)이라 신규 Stage B 설치엔 영향이 없다.
851
+ // Stage B에서의 의미 재검토는 REQ-2026-014 backlog(이번 범위에서 동작 불변).
294
852
  let crossSpawnFloorWarned = false
295
853
  const floorCheck = crossSpawnBelowFloor(targetRoot, pkg as Record<string, unknown>)
296
854
  if (floorCheck?.below) {
@@ -337,45 +895,196 @@ export function runInit(opts: InitOptions): InitResult {
337
895
  // package.json 패치 계획(쓰기 없음, 기존 키 미덮어씀)
338
896
  const packageJsonAdded: string[] = []
339
897
  const scripts = pkg.scripts ?? {}
340
- const devDeps = pkg.devDependencies ?? {}
341
- for (const [k, v] of Object.entries(REQ_SCRIPTS)) {
898
+ // Stage B: `commitgate <verb>` 를 주입한다(R1/R2). `if (!(k in scripts))` — **기존 키는 절대 덮지 않는다**.
899
+ // 미덮어씀 규칙이 "사용자 정의 req:* 보존"이며, Stage A 시절부터의 **기존 동작**이다(회귀 테스트로 고정).
900
+ for (const [k, v] of Object.entries(STAGE_B_REQ_SCRIPTS)) {
342
901
  if (!(k in scripts)) {
343
902
  scripts[k] = v
344
903
  packageJsonAdded.push(`scripts.${k}`)
345
904
  }
346
905
  }
347
- for (const [k, v] of Object.entries(REQ_DEV_DEPS)) {
348
- if (!(k in devDeps)) {
349
- devDeps[k] = v
350
- packageJsonAdded.push(`devDependencies.${k}`)
351
- }
352
- }
906
+ // ⚠️ Stage B(R3): devDeps(`tsx`·`ajv`·`cross-spawn`) **주입하지 않는다**. 이들은 `commitgate` 패키지의
907
+ // runtime `dependencies`라 `npm i -D commitgate` 시 전이 설치된다. 대상 package.json의 devDependencies는
908
+ // **건드리지 않는다**(사용자 소유 — `devDependencies.commitgate`도 사용자가 `npm i -D`로 넣은 것이다).
909
+ // `REQ_DEV_DEPS` 상수는 남는다 — `bin/uninstall.ts`가 기존 Stage A 설치본의 devDeps를 분류하는 데 쓴다.
353
910
 
354
911
  const agentsPath = join(targetRoot, 'AGENTS.md')
355
912
  const agentsCreated = !existsSync(agentsPath)
356
913
 
914
+ const agentEntrypointsSkipped = opts.noAgentEntrypoints === true
915
+ if (!agentEntrypointsSkipped) assertEntrypointPathsUsable(targetRoot)
916
+
917
+ // 기존 AGENTS.md에 계약 마커가 없으면 진입점 포인터가 **엉뚱한 SSOT**를 가리키게 된다(design R1 observation).
918
+ // 설치는 계속한다(비파괴 원칙) — 사용자가 병합하도록 알릴 뿐.
919
+ const agentsMarkerMissing =
920
+ !agentEntrypointsSkipped && !agentsCreated && !readFileSync(agentsPath, 'utf8').includes(AGENTS_CONTRACT_MARKER)
921
+
922
+ const claudeMdPath = join(targetRoot, KIT_CLAUDE_DEST_REL)
923
+ const claudeMdCreated = !agentEntrypointsSkipped && !existsSync(claudeMdPath)
924
+
925
+ // 마커가 없으면 포인터가 참조할 계약 템플릿을 **대상 repo에** 놓는다 — 그러지 않으면 복구 지시가 막다른 길이다.
926
+ const contractCopyPath = join(targetRoot, KIT_AGENTS_CONTRACT_COPY_REL)
927
+ const agentsContractCopyCreated = agentsMarkerMissing && (!existsSync(contractCopyPath) || opts.force)
928
+
929
+ // workflow/.gitignore(kit 파일, REQ-2026-012). AGENTS.md 정책: **부재 시에만** 생성, --force로도 안 덮음(D12).
930
+ // --no-agent-entrypoints와 무관(D13).
931
+ const workflowGitignorePath = join(targetRoot, KIT_GITIGNORE.dest)
932
+ 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·디렉터리·특수파일) — 그 경로를 옮기고 재시도하십시오.`)
950
+ const workflowGitignoreExists = wgLstat !== null // isFile 보장
951
+ const workflowGitignoreCreated = !workflowGitignoreExists
952
+ // 존재 & 바이트 동일 = 직전 실행이 깐 것(소유). ⚠️ add()를 타지 않으므로(D12) ownedSkips를 **직접** 채운다 —
953
+ // 아니면 커밋 전 재실행 시 stageList에서 빠져 unrelated로 오분류된다(설계 D4 축, phase-2 리뷰 R1).
954
+ const workflowGitignoreOwnedSkip = workflowGitignoreExists && sha256File(workflowGitignorePath) === sha256File(workflowGitignoreSrcAbs)
955
+ // 존재하지만 다름 = 사용자 파일(보존). **효과를 판정하지 않고 보수적으로 WARN**한다(phase-2 리뷰 P2·P3):
956
+ // check-ignore 효과 판정은 로컬 전용 소스(.git/info/exclude·전역)를 이식 가능으로 오인하고,
957
+ // 단일 샘플은 scoped negation(`!/REQ-2026-...`)을 놓친다. 그래서 "다르면 무조건 안내".
958
+ const workflowGitignoreUserDiffers = workflowGitignoreExists && !workflowGitignoreOwnedSkip
959
+ // 정책 파일이 fresh clone·CI에 전달되지 못하는가(설치 커밋에 못 담김) — ignored∧untracked면 `git add`가 fatal이다.
960
+ // 소유(생성/ownedSkip)든 사용자 파일(differs)이든, scratch 정책이 팀에 없으면 안전한 설치 안내를 낼 수 없다(phase-2 리뷰 P2).
961
+ const workflowGitignorePolicyAtRisk =
962
+ (workflowGitignoreCreated || workflowGitignoreOwnedSkip || workflowGitignoreUserDiffers) &&
963
+ gitIsIgnored(targetRoot, KIT_GITIGNORE.dest) &&
964
+ !gitIsTracked(targetRoot, KIT_GITIGNORE.dest)
965
+
966
+ // 설치 **전** 워킹트리(쓰기 전 스냅샷). `.gitignore`의 dirty 여부 판정에도 쓰이므로 계획보다 먼저 찍는다.
967
+ const porcelain = gitStatusEntries(targetRoot)
968
+ const nmWillDirty = nodeModulesWillDirty(targetRoot)
969
+
970
+ // 산출물 계획을 **쓰기 전에** 확정한다(DEC-011-9). ignore 검사·복사·설치 후 안내가 이 하나를 공유한다.
971
+ const plan = planInstall(targetRoot, opts.force, packageManager, {
972
+ configWillWrite: configToWrite !== null,
973
+ packageJsonWillWrite: packageJsonAdded.length > 0,
974
+ agentsWillCreate: agentsCreated,
975
+ claudeMdWillCreate: claudeMdCreated,
976
+ contractCopyWillCreate: agentsContractCopyCreated,
977
+ agentEntrypointsSkipped,
978
+ gitignoreWillJoin: gitignoreJoinsInstall(nmWillDirty, porcelain),
979
+ workflowGitignoreWillCreate: workflowGitignoreCreated,
980
+ workflowGitignoreOwnedSkip,
981
+ })
982
+ const artifacts = planArtifactPaths(plan)
983
+
984
+ // gitignore 판정(D5). 계약 포인터가 무시되면 설치 목적(팀·CI 공유)이 조용히 무너진다 → WARN/strict throw.
985
+ // 그 밖의 산출물(lockfile 등)은 무시돼도 정당한 정책일 수 있으므로 stage 목록에서만 조용히 뺀다.
986
+ const gitIgnoredArtifacts = findIgnoredArtifacts(targetRoot, artifacts)
987
+ const ignoredPointers = gitIgnoredArtifacts.filter((p) => CONTRACT_POINTER_RELPATHS.includes(p))
988
+
989
+ // 설치 **전** 워킹트리 3분류(DEC-011-11). 쓰기 뒤에 찍으면 CommitGate 산출물과 섞여 구분할 수 없다.
990
+ const preexistingDirty = classifyPreexistingDirty(porcelain, artifacts)
991
+
992
+ if (ignoredPointers.length > 0) {
993
+ const msg = ignoredPointerMessage(ignoredPointers)
994
+ if (opts.strict) throw new Error(`[--strict] ${msg}`)
995
+ console.warn(`⚠️ ${msg}\n (설치는 계속 — 강제 중단하려면 --strict)`)
996
+ }
997
+
998
+ // `.gitignore`를 설치 커밋에 담아야 하는데 그 파일 **자신이** 무시된다(로컬 exclude·전역 ignore).
999
+ // `git add`가 fatal이고, 규칙이 커밋되지 않아 팀원의 fresh clone에서 `?? node_modules/`가 되살아난다.
1000
+ // 안전한 안내를 만들 수 없으므로 알리고(기본) 중단한다(--strict) — phase-6 리뷰 R6.
1001
+ if (plan.gitignoreRel !== null && gitIgnoredArtifacts.includes('.gitignore')) {
1002
+ const msg =
1003
+ `.gitignore 자체가 무시되고 있어(.git/info/exclude 또는 전역 ignore) 설치 커밋에 담을 수 없습니다.\n` +
1004
+ ` node_modules 무시 규칙이 팀원의 fresh clone에 따라가지 않아 그쪽 req:new 가 막힙니다.\n` +
1005
+ ` 로컬 exclude에서 .gitignore 를 빼고 저장소에 커밋하십시오.`
1006
+ if (opts.strict) throw new Error(`[--strict] ${msg}`)
1007
+ console.warn(`⚠️ ${msg}\n (설치는 계속 — 커밋 안내는 생략됩니다)`)
1008
+ }
1009
+
1010
+ // workflow/.gitignore가 ignored∧untracked면 설치 커밋에 못 담겨 팀·CI에 scratch 정책이 없다(phase-2 리뷰 R3·R6).
1011
+ // ⚠️ `workflowGitignorePolicyAtRisk`로 통합 판정 — created/ownedSkip(산출물)뿐 아니라 **user-differs**(artifacts에 없고
1012
+ // porcelain에도 안 나타나는 사용자 파일)도 포함한다. 이것을 빼면 --strict가 그 상태에서 쓰기 전에 안 막는다(R6 gap).
1013
+ if (workflowGitignorePolicyAtRisk) {
1014
+ const msg =
1015
+ `${KIT_GITIGNORE.dest} 가 무시되고 있어(예: 루트 규칙의 \`**/.gitignore\`) 설치 커밋에 담을 수 없습니다.\n` +
1016
+ ` 티켓 scratch 무시 규칙이 팀원의 fresh clone·CI에 따라가지 않습니다.\n` +
1017
+ ` 그 파일을 무시하는 규칙을 걷어내고 저장소에 커밋하십시오.`
1018
+ if (opts.strict) throw new Error(`[--strict] ${msg}`)
1019
+ console.warn(`⚠️ ${msg}\n (설치는 계속 — 안전한 커밋 안내는 생략됩니다)`)
1020
+ }
1021
+ // staged·overlapping이 있으면 **안전한 커밋 안내를 만들 수 없다**(커밋이 인덱스 전체를 담고, 겹침은 사후 분리 불가).
1022
+ // 기본 모드는 설치를 막지 않는다(비파괴·비-breaking) — 안내를 내지 않을 뿐. `--strict`는 쓰기 전에 중단한다.
1023
+ if (opts.strict && (preexistingDirty.staged.length > 0 || preexistingDirty.overlapping.length > 0)) {
1024
+ const lines = [
1025
+ ...preexistingDirty.staged.map((p) => ` staged ${p}`),
1026
+ ...preexistingDirty.overlapping.map((p) => ` 설치분과 겹침 ${p}`),
1027
+ ]
1028
+ throw new Error(
1029
+ `[--strict] 설치 전 워킹트리에 변경이 있어 안전한 설치 커밋을 만들 수 없습니다:\n${lines.join('\n')}\n` +
1030
+ ` staged 변경은 설치 커밋에 함께 들어가고, 겹치는 변경은 사후 분리가 불가능합니다. 먼저 커밋하거나 되돌리세요.`,
1031
+ )
1032
+ }
1033
+
357
1034
  // ══ Apply: 여기부터 쓰기(preflight 전부 통과 후에만) ═════════════════
358
- const copied: string[] = []
359
- const skipped: string[] = []
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))
363
- copyInto(schemaFiles, PACKAGE_ROOT, targetRoot, opts, copied, skipped)
1035
+ const copied = plan.copies.map((c) => c.destRel)
1036
+ const skipped = plan.skips
364
1037
 
365
1038
  if (!opts.dryRun) {
1039
+ applyCopies(targetRoot, plan)
366
1040
  if (configToWrite) writeFileSync(cfgPath, JSON.stringify(configToWrite, null, 2) + '\n', 'utf8')
367
1041
  if (packageJsonAdded.length > 0) {
368
1042
  pkg.scripts = scripts
369
- pkg.devDependencies = devDeps
1043
+ // ⚠️ Stage B(R3): `pkg.devDependencies`를 **재대입하지 않는다**. 주입이 없으므로 파싱된 원본이 그대로 직렬화되고,
1044
+ // devDependencies가 없던 package.json에 빈 `{}`를 새로 만들어 넣는 부작용도 없다.
370
1045
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
371
1046
  }
372
1047
  if (agentsCreated) copyFileSync(join(PACKAGE_ROOT, 'AGENTS.template.md'), agentsPath)
1048
+ // CLAUDE.md는 AGENTS.md와 동일 정책: **부재 시에만** 생성(--force로도 덮어쓰지 않는다 — 사용자 파일일 수 있다).
1049
+ if (claudeMdCreated) copyFileSync(join(PACKAGE_ROOT, KIT_CLAUDE_TEMPLATE_REL), claudeMdPath)
1050
+ if (agentsContractCopyCreated) copyFileSync(join(PACKAGE_ROOT, 'AGENTS.template.md'), contractCopyPath)
1051
+ // workflow/.gitignore도 부재 시에만 생성(--force로도 안 덮음, D12). workflow/는 KIT_COPY_RELPATHS 복사로 이미 존재.
1052
+ if (workflowGitignoreCreated) {
1053
+ mkdirSync(dirname(workflowGitignorePath), { recursive: true })
1054
+ copyFileSync(workflowGitignoreSrcAbs, workflowGitignorePath)
1055
+ }
1056
+ }
1057
+
1058
+ if (workflowGitignoreUserDiffers)
1059
+ console.warn(
1060
+ `⚠️ 기존 ${KIT_GITIGNORE.dest} 가 kit 템플릿과 다릅니다(보존 — --force로도 덮지 않음).\n` +
1061
+ ` 아래 규칙이 (뒤에 \`!\` 부정 override 없이) 있는지 확인·병합하십시오 — 없으면 티켓 scratch가 git status에 계속 잡힙니다:\n` +
1062
+ kitGitignoreRules().map((r) => ` ${r}`).join('\n'),
1063
+ )
1064
+
1065
+ if (agentsMarkerMissing) {
1066
+ // phase-3a R1 observation: 사본을 **새로 놓았을 때**와 **기존 것을 보존했을 때**의 문구가 달라야 한다.
1067
+ // "설치했습니다"가 두 경우 모두에 나오면, 편집된 사본을 그대로 둔 사용자가 새 템플릿을 받았다고 오해한다.
1068
+ const copyNote = opts.dryRun
1069
+ ? `계약 템플릿을 ${KIT_AGENTS_CONTRACT_COPY_REL} 로 설치할 예정입니다.`
1070
+ : agentsContractCopyCreated
1071
+ ? `계약 템플릿을 ${KIT_AGENTS_CONTRACT_COPY_REL} 로 함께 설치했습니다.`
1072
+ : `${KIT_AGENTS_CONTRACT_COPY_REL} 가 이미 있어 보존했습니다(덮어쓰려면 --force).`
1073
+ console.warn(
1074
+ `⚠️ 기존 AGENTS.md에 ${AGENTS_CONTRACT_MARKER} 마커가 없습니다 — .claude/·.cursor/ 진입점이 가리킬 CommitGate 계약이 그 파일에 없습니다.\n` +
1075
+ ` ${copyNote} 그 내용을 AGENTS.md에 병합한 뒤 ${KIT_AGENTS_CONTRACT_COPY_REL} 를 지우세요(설치는 계속됩니다).`,
1076
+ )
373
1077
  }
374
1078
 
375
1079
  return {
376
1080
  targetRoot,
377
1081
  copied,
378
1082
  skipped,
1083
+ artifacts,
1084
+ gitIgnoredArtifacts,
1085
+ lockfileRel: plan.lockfileRel,
1086
+ nodeModulesWillDirty: nmWillDirty,
1087
+ preexistingDirty,
379
1088
  configAction,
380
1089
  configKeysAdded,
381
1090
  packageJsonAdded,
@@ -383,14 +1092,141 @@ export function runInit(opts: InitOptions): InitResult {
383
1092
  packageManager,
384
1093
  crossSpawnFloorWarned,
385
1094
  dryRun: opts.dryRun,
1095
+ claudeMdCreated,
1096
+ agentsMarkerMissing,
1097
+ agentsContractCopyCreated,
1098
+ agentEntrypointsSkipped,
1099
+ workflowGitignoreCreated,
1100
+ workflowGitignoreUserDiffers,
1101
+ workflowGitignorePolicyAtRisk,
386
1102
  }
387
1103
  }
388
1104
 
1105
+ /**
1106
+ * 설치 후 `git add` 대상. 산출물 전수에서 **무시되고 untracked인 것**만 뺀다 — `git add <ignored>`는 fatal이다.
1107
+ * `planArtifactPaths`(preflight)와 같은 목록을 소비하므로 두 축이 갈라질 수 없다(DEC-011-9).
1108
+ */
1109
+ export function stageList(artifacts: readonly string[], gitIgnoredArtifacts: readonly string[]): string[] {
1110
+ const ignored = new Set(gitIgnoredArtifacts)
1111
+ return artifacts.filter((p) => !ignored.has(p))
1112
+ }
1113
+
1114
+ /** 인용 없이 안전한 경로(영숫자 + 경로 구분자 + 흔한 구두점). */
1115
+ const SHELL_SAFE_PATH = /^[A-Za-z0-9._+\-/\\:@]+$/
1116
+
1117
+ /**
1118
+ * 안내에 넣을 경로를 셸에 안전하게 인용한다(phase-6 리뷰 R5).
1119
+ *
1120
+ * 공백이 든 경로를 그대로 인쇄하면 `cd C:\Work\My Repo`가 PowerShell에서 인자 두 개로 쪼개지고,
1121
+ * `git stash push -u -- notes today.txt`는 pathspec 두 개가 되어 그 파일이 dirty로 남는다.
1122
+ * 큰따옴표는 sh·PowerShell·cmd가 모두 경로 묶음으로 인정한다.
1123
+ *
1124
+ * ⚠️ 경로에 `"`·`` ` ``·`$`가 있으면 셸마다 이스케이프 규칙이 달라 **단일 안전 표기가 없다.**
1125
+ * 그럴 땐 묶기만 하고 `pathNeedsManualQuoting`이 사용자에게 수동 확인을 지시하게 한다.
1126
+ */
1127
+ export function quoteForShell(p: string): string {
1128
+ return SHELL_SAFE_PATH.test(p) ? p : `"${p}"`
1129
+ }
1130
+
1131
+ /**
1132
+ * 큰따옴표로도 셸 간 안전을 보장할 수 없는 경로인가.
1133
+ *
1134
+ * - `"` : 인용을 닫는다.
1135
+ * - `` ` ``·`$` : PowerShell이 큰따옴표 **안에서** 확장·이스케이프한다.
1136
+ * - `%`·`!` : **cmd.exe가 큰따옴표 안에서도** 환경변수(`%VAR%`)와 지연확장(`!VAR!`)을 치환한다.
1137
+ * `notes %USERPROFILE%.txt` 같은 경로는 다른 pathspec으로 바뀌어 엉뚱한 파일을 stash할 수 있다.
1138
+ *
1139
+ * 이런 경로가 하나라도 있으면 복붙 명령을 **내지 않는다** — 잘못된 명령보다 명령 없음이 낫다.
1140
+ */
1141
+ export function pathNeedsManualQuoting(p: string): boolean {
1142
+ return /["`$%!]/.test(p)
1143
+ }
1144
+
1145
+ /**
1146
+ * 설치 직후 "다음:" 안내(D4). **순수 함수** — `InitResult`만 보고 줄 배열을 만든다(테스트 가능).
1147
+ *
1148
+ * 세 가지 규칙:
1149
+ * 1. **`git add -A` 금지**(DEC-011-7). brownfield의 무관한 변경과 `.env`가 함께 커밋되고,
1150
+ * 이어지는 `req:review-codex`가 staged diff 전문을 외부로 전송한다.
1151
+ * 2. **shell 연산자 금지**(DEC-011-8). `&&`는 Windows PowerShell 5.1·cmd.exe에 없다.
1152
+ * 3. **안전한 안내를 만들 수 없으면 내지 않는다**(DEC-011-11). staged 변경은 커밋이 삼키고,
1153
+ * 산출물과 겹치는 tracked 변경은 사후 분리가 불가능하다. 잘못된 안내보다 안내 없음이 낫다.
1154
+ */
1155
+ export function installGuidance(r: InitResult): string[] {
1156
+ const { staged, overlapping, unrelated } = r.preexistingDirty
1157
+ // `.gitignore`를 담아야 하는데 그 파일 자신이 무시되면 `git add`가 fatal이고 규칙이 커밋되지 않는다 →
1158
+ // 이식 가능한 clean-tree를 보장할 수 없다. 안내를 내지 않는다(phase-6 리뷰 R6).
1159
+ const gitignoreUnstageable = r.artifacts.includes('.gitignore') && r.gitIgnoredArtifacts.includes('.gitignore')
1160
+ // 어떤 셸 인용으로도 안전하지 않은 경로 — cmd.exe는 큰따옴표 안에서도 `%VAR%`·`!VAR!`를 치환한다(R7).
1161
+ const risky = [r.targetRoot, ...r.artifacts, ...unrelated].filter(pathNeedsManualQuoting)
1162
+ const unsafe =
1163
+ staged.length > 0 || overlapping.length > 0 || gitignoreUnstageable || risky.length > 0 || r.workflowGitignorePolicyAtRisk
1164
+
1165
+ const cdLine = risky.includes(r.targetRoot) ? ` 1. 저장소 루트로 이동: ${r.targetRoot}` : ` 1. cd ${quoteForShell(r.targetRoot)}`
1166
+ const out: string[] = ['', '다음:', cdLine, ` 2. ${r.packageManager} install`]
1167
+ out.push(` 3. codex --version # 리뷰 실호출 전제(미설치면 review-codex --run이 fail-closed)`)
1168
+ out.push(` 4. req.config.json 확인(branchPrefix 등)`)
1169
+
1170
+ if (unsafe) {
1171
+ out.push('')
1172
+ out.push(' ⚠️ 안전한 커밋 안내를 만들 수 없습니다.')
1173
+ for (const p of staged) out.push(` staged (커밋에 함께 들어갑니다) ${p}`)
1174
+ for (const p of overlapping) out.push(` 설치분과 겹침 (사후 분리 불가) ${p}`)
1175
+ if (gitignoreUnstageable)
1176
+ out.push(' .gitignore 자체가 무시됨 (규칙이 clone에 따라가지 않음)')
1177
+ if (r.workflowGitignorePolicyAtRisk)
1178
+ out.push(` ${KIT_GITIGNORE.dest} 가 무시됨 — 설치 커밋에 못 담겨 fresh clone·CI에 scratch 정책이 없습니다`)
1179
+ for (const p of risky)
1180
+ out.push(` 셸 특수문자(" \` $ % !) 포함 — 어떤 인용으로도 복붙이 안전하지 않음: ${p}`)
1181
+ out.push(' 위를 커밋하거나 되돌린 뒤, `git status` 로 직접 확인하며 설치분만 커밋하십시오.')
1182
+ out.push(` 그다음: ${runScriptCmd(r.packageManager, 'req:new', '<slug> --run')}`)
1183
+ return out
1184
+ }
1185
+
1186
+ const toStage = stageList(r.artifacts, r.gitIgnoredArtifacts)
1187
+ let n = 5
1188
+ if (r.nodeModulesWillDirty) {
1189
+ // `<pm> install`이 만든 `?? node_modules/`가 clean-tree 게이트를 막는다. README의 `git init && npm init -y`
1190
+ // 경로에는 .gitignore가 없어 **반드시** 걸린다. `.gitignore`는 이미 `artifacts`에 들어 있으므로
1191
+ // stage 목록에 자동으로 포함되고, tracked인데 이미 dirty하면 위의 unsafe 분기가 먼저 막는다.
1192
+ out.push(` ${n++}. \`node_modules\` 가 .gitignore 되어 있지 않습니다. 2단계 install 이 만든 그 디렉터리가`)
1193
+ out.push(` 워킹트리를 dirty 하게 만들어 req:new 가 막힙니다. .gitignore 에 \`node_modules/\` 를 추가하십시오.`)
1194
+ }
1195
+ out.push(` ${n++}. 설치분만 stage 하십시오. 전체를 담는 stage(-A / .)는 쓰지 마십시오 — 무관한 변경·.env 가`)
1196
+ out.push(` 함께 커밋되고, 이어지는 req:review-codex 가 staged diff 전문을 외부로 전송합니다.`)
1197
+ if (r.lockfileRel !== null)
1198
+ out.push(` (2단계 install 을 먼저 실행해야 ${r.lockfileRel} 이 존재합니다. lockfile 을 만들지 않는 설정이라면 그 경로는 빼십시오.)`)
1199
+ out.push(` git add -- ${toStage.map(quoteForShell).join(' ')}`)
1200
+ out.push(` git status # 의도한 것만 staged 인지 눈으로 확인`)
1201
+ out.push(` git commit -m "chore: install commitgate"`)
1202
+
1203
+ // ⚠️ 사용자 소유 workflow/.gitignore(differs)는 stash 대상에서 뺀다(phase-2 리뷰 P4). stash하면 그 정책 파일이
1204
+ // 사라져 fresh clone에서 scratch가 다시 나타나고 다음 req:new가 막힌다. 별도로 확인·커밋하도록 안내한다.
1205
+ const userGitignoreDirty = r.workflowGitignoreUserDiffers && unrelated.includes(KIT_GITIGNORE.dest)
1206
+ const stashUnrelated = userGitignoreDirty ? unrelated.filter((p) => p !== KIT_GITIGNORE.dest) : unrelated
1207
+
1208
+ if (userGitignoreDirty) {
1209
+ out.push(` ${n++}. ${KIT_GITIGNORE.dest} 는 당신의 파일입니다(미커밋). stash 하지 말고 —`)
1210
+ out.push(` kit 규칙이 있는지 확인한 뒤 **직접 커밋**하십시오(fresh clone·CI에 scratch 정책이 있어야 합니다).`)
1211
+ }
1212
+ if (stashUnrelated.length > 0) {
1213
+ // ⚠️ bare `git stash -u`는 너무 넓다 — `node_modules/`처럼 gitignore되지 않은 산출물까지 쓸어 가
1214
+ // 방금 설치한 tsx가 사라지고 req:new가 죽는다(실측). 경로를 명시한다(DEC-011-7과 같은 원칙).
1215
+ // `-u` 없이는 untracked가 남아 clean-tree 게이트가 여전히 실패한다(design 리뷰 R5).
1216
+ out.push(` ${n++}. 설치 커밋 뒤, 설치 전부터 있던 아래 무관한 변경을 커밋하거나 치우십시오`)
1217
+ out.push(` (req:new 는 clean 워킹트리를 요구합니다):`)
1218
+ out.push(` git stash push -u -- ${stashUnrelated.map(quoteForShell).join(' ')}`)
1219
+ }
1220
+ out.push(` ${n}. ${runScriptCmd(r.packageManager, 'req:new', '<slug> --run')}`)
1221
+ return out
1222
+ }
1223
+
389
1224
  export function parseArgs(argv: string[]): InitOptions {
390
1225
  let dir = process.cwd()
391
1226
  let force = false
392
1227
  let dryRun = false
393
1228
  let strict = false
1229
+ let noAgentEntrypoints = false
394
1230
  for (let i = 0; i < argv.length; i++) {
395
1231
  const a = argv[i]
396
1232
  if (a === '--dir') {
@@ -404,6 +1240,8 @@ export function parseArgs(argv: string[]): InitOptions {
404
1240
  dryRun = true
405
1241
  } else if (a === '--strict') {
406
1242
  strict = true
1243
+ } else if (a === '--no-agent-entrypoints') {
1244
+ noAgentEntrypoints = true
407
1245
  } else if (a === '-h' || a === '--help') {
408
1246
  printHelp()
409
1247
  process.exit(0)
@@ -411,27 +1249,40 @@ export function parseArgs(argv: string[]): InitOptions {
411
1249
  throw new Error(`알 수 없는 인자: ${a}`)
412
1250
  }
413
1251
  }
414
- return { dir: resolve(dir), force, dryRun, strict }
1252
+ return { dir: resolve(dir), force, dryRun, strict, noAgentEntrypoints }
415
1253
  }
416
1254
 
417
1255
  function printHelp(): void {
418
- console.log(`commitgate — AI REQ workflow(커밋 게이트) kit 설치
1256
+ console.log(`commitgate — AI REQ workflow(커밋 게이트) 설치
1257
+
1258
+ ⚠️ commitgate 를 **먼저 devDependency 로 설치**해야 합니다:
1259
+ npm install -D commitgate
1260
+ 그다음 이 명령을 실행합니다. 실행 코드는 대상 repo 에 복사되지 않고
1261
+ node_modules/commitgate 에서 돕니다(req:* 스크립트가 그리로 dispatch).
419
1262
 
420
1263
  사용법:
421
- npx commitgate [--dir <대상repo>] [--force] [--dry-run] [--strict]
422
- npx commitgate uninstall [--dir <대상repo>] # 제거 계획만 출력(아무것도 지우지 않음)
1264
+ npx commitgate [init] [--dir <대상repo>] [--force] [--dry-run] [--strict]
1265
+ npx commitgate migrate [--apply] [--dir <대상repo>] # 예전 vendored 설치본 → 런타임 패키지(기본: 계획만)
1266
+ npx commitgate uninstall [--dir <대상repo>] # 제거 계획만 출력(아무것도 지우지 않음)
423
1267
 
424
1268
  옵션:
425
1269
  --dir <path> 대상 repo 루트(기본: 현재 디렉터리)
426
1270
  --force 기존 kit 파일 덮어쓰기(기본: 스킵)
427
1271
  --dry-run 변경 없이 수행 예정 목록만 출력
428
- --strict 기존 cross-spawn이 보안 하한(>=7.0.6) 미만이면 경고 대신 중단(fail-closed)
1272
+ --strict 정합성 경고를 설치 실패로 취급(fail-closed)
1273
+ --no-agent-entrypoints
1274
+ .claude/·.cursor/·CLAUDE.md 진입점 설치를 건너뛴다
429
1275
  -h, --help 도움말
430
1276
 
1277
+ 설치하는 것: workflow 스키마 2종 · reviewer persona · req.config.json ·
1278
+ AGENTS.md/CLAUDE.md·에이전트 진입점 · package.json 의 req:* 스크립트.
1279
+ 설치하지 않는 것: scripts/req/** 실행 코드 · tsx/ajv/cross-spawn devDependency
1280
+ (전부 commitgate 패키지에 들어 있습니다).
1281
+
431
1282
  설치 후:
432
- 1. <대상repo>에서 의존성 설치(감지된 패키지매니저)
433
- 2. codex CLI 설치 확인(리뷰 실호출용)
434
- 3. req.config.json 조정(branchPrefix/ticketRoot )
1283
+ 1. codex CLI 설치 확인(리뷰 실호출용)
1284
+ 2. req.config.json 조정(branchPrefix/ticketRoot )
1285
+ 3. 설치분 커밋(안내가 stage 할 경로를 알려 줍니다)
435
1286
  4. 첫 티켓 생성:
436
1287
  npm → npm run req:new -- <slug> --run
437
1288
  pnpm → pnpm req:new <slug> --run
@@ -458,14 +1309,22 @@ export function main(argv: string[]): void {
458
1309
  `${tag} package.json: ${r.packageJsonAdded.length > 0 ? '추가 ' + r.packageJsonAdded.join(', ') : '변경 없음'}`,
459
1310
  )
460
1311
  console.log(`${tag} AGENTS.md: ${r.agentsCreated ? '템플릿 생성' : '이미 존재(유지)'}`)
461
- if (r.crossSpawnFloorWarned) console.log(`${tag} ⚠️ cross-spawn 버전 하한 경고(위 참조) — 강제 중단은 --strict`)
462
- if (!r.dryRun) {
463
- console.log(`\n다음:`)
464
- console.log(` 1. cd ${r.targetRoot} && ${r.packageManager} install`)
465
- console.log(` 2. codex --version # 리뷰 실호출 전제(미설치면 review-codex --run이 fail-closed)`)
466
- console.log(` 3. req.config.json 확인(branchPrefix 등)`)
467
- console.log(` 4. ${runScriptCmd(r.packageManager, 'req:new', '<slug> --run')}`)
1312
+ if (r.agentEntrypointsSkipped) console.log(`${tag} 에이전트 진입점: 건너뜀(--no-agent-entrypoints)`)
1313
+ else {
1314
+ console.log(`${tag} CLAUDE.md: ${r.claudeMdCreated ? '템플릿 생성' : '이미 존재(유지)'}`)
1315
+ if (r.agentsContractCopyCreated) console.log(`${tag} ${KIT_AGENTS_CONTRACT_COPY_REL}: 계약 템플릿 사본 생성(AGENTS.md에 병합 후 삭제)`)
468
1316
  }
1317
+ // workflow/.gitignore는 --no-agent-entrypoints와 무관하게 보고(D13).
1318
+ console.log(`${tag} ${KIT_GITIGNORE.dest}: ${r.workflowGitignoreCreated ? '생성' : '이미 존재(유지)'}`)
1319
+ if (r.gitIgnoredArtifacts.length > 0)
1320
+ console.log(`${tag} .gitignore로 제외되는 산출물: ${r.gitIgnoredArtifacts.join(', ')}`)
1321
+ const dirtyCount = r.preexistingDirty.staged.length + r.preexistingDirty.overlapping.length + r.preexistingDirty.unrelated.length
1322
+ if (dirtyCount > 0)
1323
+ console.log(
1324
+ `${tag} 설치 전 워킹트리: staged ${r.preexistingDirty.staged.length} / 설치분과 겹침 ${r.preexistingDirty.overlapping.length} / 무관 ${r.preexistingDirty.unrelated.length}`,
1325
+ )
1326
+ if (r.crossSpawnFloorWarned) console.log(`${tag} ⚠️ cross-spawn 버전 하한 경고(위 참조) — 강제 중단은 --strict`)
1327
+ if (!r.dryRun) for (const line of installGuidance(r)) console.log(line)
469
1328
  }
470
1329
 
471
1330
  /**