commitgate 0.9.5 → 0.9.8

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.
@@ -1,252 +1,255 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * req:new — AI REQ 워크플로우 1차 (단계 4A): REQ 티켓 + feat/req-* 브랜치 생성.
4
- *
5
- * 설계 근거: DEC-WF-020(D11) — REQ는 main에서 feat/req-* 브랜치로 시작한다.
6
- * - state.json은 **BOM 없이** 생성(Node, review-codex의 writeState 재사용).
7
- * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
- * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
9
- *
10
- * 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."] [--successor-of <REQ-id>]
11
- * --successor-of: 대체 REQ. 부모에 replace 종결(human-resolution) 기록이 있어야 하며, 없으면 fail-closed
12
- * (티켓 미생성). lineage는 부모 state에서 읽는다(REQ-2026-029 A-2b).
13
- */
14
- import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
15
- import { join, relative } from 'node:path'
16
- import { pathToFileURL } from 'node:url'
17
- import { writeState, loadState, resolveSuccessorLineage, type WorkflowState, type SuccessorOf } from './review-codex'
18
- import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
19
- import { createGitAdapter, type GitAdapter } from './lib/adapters'
20
- import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
21
- import { isToolOutputScratch } from './lib/scratch'
22
-
23
- // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
24
- let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
25
-
26
- function git(args: string[]): string {
27
- return gitAdapter.exec(args)
28
- }
29
-
30
- /**
31
- * `req:new` 전용 clean-tree 위반 목록(REQ-2026-012 D7·D8).
32
- *
33
- * 허용하는 것은 기존 티켓 직계의 **untracked 도구 산출물** 두 종류뿐이다. `state.json`, `responses/**`,
34
- * staged-only 변경, rename/copy 등 그 밖의 모든 상태는 그대로 위반이다. review용
35
- * `findUnstagedOrUntracked`는 staged-only(`M `)를 통과시키므로 여기서 재사용하지 않는다.
36
- */
37
- export function findReqNewDirtyEntries(rawStatusZ: string, ticketRoot: string): StatusEntry[] {
38
- return parseStatusZ(rawStatusZ).filter((entry) => !isToolOutputScratch(entry, ticketRoot))
39
- }
40
-
41
- /** slug 검증: kebab-case(a-z0-9, '-' 구분). */
42
- export function validateSlug(slug: string): void {
43
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
44
- throw new Error(`slug는 kebab-case(a-z0-9, '-' 구분)여야 함: "${slug}"`)
45
- }
46
-
47
- /** REQ id 채번(순수): 같은 연도 기존 id의 max+1, 3자리 zero-pad. */
48
- export function nextReqId(year: number, existingIds: string[]): string {
49
- const prefix = `REQ-${year}-`
50
- const maxN = existingIds
51
- .filter((id) => id.startsWith(prefix))
52
- .map((id) => Number.parseInt(id.slice(prefix.length), 10))
53
- .filter((n) => Number.isFinite(n))
54
- .reduce((a, b) => Math.max(a, b), 0)
55
- return `${prefix}${String(maxN + 1).padStart(3, '0')}`
56
- }
57
-
58
- export function branchName(reqId: string, slug: string, branchPrefix: string): string {
59
- return `${branchPrefix}${reqId.replace(/^REQ-/, '')}-${slug}`
60
- }
61
-
62
- /**
63
- * `--run` 성공 후 다음 단계 안내(DEC-011-1). **config 로드 이후**이므로 pm별 실행 형식으로 파생한다.
64
- *
65
- * ⚠️ `DEFAULTS.packageManager`(= `'pnpm'`)를 폴백으로 쓰지 마라. `bin/init.ts`의 감지 폴백은 `'npm'`이라
66
- * 두 값이 갈라져 있고, 그걸 문구에 끌어오면 npm 프로젝트가 pnpm 명령을 안내받는다 — 이 REQ가 고치는 결함이다.
67
- * config가 없는 지점(헤더 주석·early throw)은 pm-중립 bare 표기를 쓴다.
68
- */
69
- export function nextStepHint(pm: PackageManager, reqId: string): string {
70
- const id = reqId.replace(/^REQ-/, '')
71
- return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
72
- }
73
-
74
- export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH', successorOf?: SuccessorOf): WorkflowState {
75
- return {
76
- id: reqId,
77
- branch,
78
- phase: 'INTAKE',
79
- risk_level: risk,
80
- codex_thread_id: null,
81
- review_base_sha: null,
82
- review_diff_hash: null,
83
- approved_diff_hash: null,
84
- commit_allowed: false,
85
- // DEC-WF-027 design-first / phase-gated 상태(Phase 2 도입). design 승인·phase 추적은 후속 단계에서 사용.
86
- design_approved: false,
87
- design_approved_hash: null,
88
- current_phase: null,
89
- phases: [],
90
- // REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
91
- approval_evidence_required: true,
92
- // REQ-2026-027 D1: review series 모델 버전. 리뷰 전에도 존재해 "새 ticket(레코드 없음)"과
93
- // "legacy(필드 부재)"를 구분한다. 필드 부재 = legacy 재리뷰 시 AWAIT_HUMAN/throw(자동 초기화 금지).
94
- review_series_model_version: 1,
95
- // REQ-2026-029 D3: 대체 REQ면 부모 lineage(--successor-of). review_series로 예산 부모 이력만 보존.
96
- ...(successorOf ? { successor_of: successorOf } : {}),
97
- } as WorkflowState
98
- }
99
-
100
- function listExistingReqIds(workflowDir: string): string[] {
101
- if (!existsSync(workflowDir)) return []
102
- return readdirSync(workflowDir, { withFileTypes: true })
103
- .filter((d) => d.isDirectory() && /^REQ-\d{4}-\d+$/.test(d.name))
104
- .map((d) => d.name)
105
- }
106
-
107
- export interface Opts {
108
- slug: string | null
109
- risk: 'LOW' | 'HIGH'
110
- title: string | null
111
- run: boolean
112
- root: string | null
113
- successorOf: string | null // REQ-2026-029 D3: 부모 REQ id(대체 REQ lineage)
114
- }
115
-
116
- /** 인자 파싱(fail-closed): 잘못된 --risk·값 누락·알 없는 옵션은 즉시 throw(조용한 fallback 금지). */
117
- export function parseArgs(argv: string[]): Opts {
118
- const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null, successorOf: null }
119
- for (let i = 0; i < argv.length; i++) {
120
- const a = argv[i]
121
- if (a === undefined) continue
122
- // bare `--`는 POSIX end-of-options 마커(DEC-011-3). pnpm/yarn은 이를 스크립트에 그대로 넘긴다.
123
- if (a === '--') {
124
- continue
125
- } else if (a === '--run') {
126
- o.run = true
127
- } else if (a === '--root') {
128
- const v = argv[++i]
129
- if (v === undefined) throw new Error('--root 값 필요')
130
- o.root = v
131
- } else if (a === '--successor-of') {
132
- const v = argv[++i]
133
- if (v === undefined) throw new Error('--successor-of 값 필요(부모 REQ id)')
134
- o.successorOf = v
135
- } else if (a === '--risk') {
136
- const v = argv[++i]
137
- if (v !== 'LOW' && v !== 'HIGH')
138
- throw new Error(`--risk 값은 LOW 또는 HIGH여야 함 (받음: ${v ?? '(없음)'})`)
139
- o.risk = v
140
- } else if (a === '--title') {
141
- const v = argv[++i]
142
- if (v === undefined) throw new Error('--title 값 필요')
143
- o.title = v
144
- } else if (a.startsWith('-')) {
145
- throw new Error(`알 없는 옵션: ${a}`)
146
- } else {
147
- o.slug = a
148
- }
149
- }
150
- return o
151
- }
152
-
153
- export function main(argv: string[] = process.argv.slice(2)): void {
154
- const o = parseArgs(argv)
155
- // ⚠️ loadConfig 이전이라 pm을 모른다 → pm-중립 bare 표기(DEFAULTS 폴백 금지, DEC-011-1).
156
- if (!o.slug) throw new Error('slug 필요 (예: req:new camera-hardfail --run)')
157
- validateSlug(o.slug)
158
-
159
- const cfg = loadConfig({ root: o.root })
160
- gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
161
- const dd: DesignDocs = cfg.designDocs
162
-
163
- const year = new Date().getFullYear()
164
- const reqId = nextReqId(year, listExistingReqIds(cfg.workflowDirAbs))
165
- const branch = branchName(reqId, o.slug, cfg.branchPrefix)
166
- const ticketDir = join(cfg.workflowDirAbs, reqId)
167
- // config 문자열이 `.`·`workflow/.`처럼 같은 위치를 다르게 표현해도 Git이 내는 canonical repo-relative 경로와 맞춘다.
168
- const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
169
- const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
170
-
171
- // REQ-2026-029 D3: --successor-of lineage 해소. **branch 생성·mkdir 前**에 검증(design-r01 observation)
172
- // 부모 없음·replace 기록 없음·형식 위반이면 여기서 throw해 티켓이 생성되지 않는다(R6 fail-closed).
173
- let successorOf: SuccessorOf | undefined
174
- if (o.successorOf !== null) {
175
- const parentId = o.successorOf.startsWith('REQ-') ? o.successorOf : `REQ-${o.successorOf}`
176
- const parentDir = join(cfg.workflowDirAbs, parentId)
177
- let parentState: WorkflowState
178
- try {
179
- parentState = loadState(parentDir)
180
- } catch {
181
- throw new Error(`--successor-of ${parentId}: 부모 티켓 state를 읽을 수 없다(${parentDir})`)
182
- }
183
- // recorded_at은 자식 생성 시각(부모 값 아님). dry-run에선 검증만 하고 값은 버린다.
184
- successorOf = resolveSuccessorLineage(parentState, parentId, new Date().toISOString())
185
- }
186
-
187
- if (!o.run) {
188
- console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
189
- console.log(` REQ : ${reqId}`)
190
- console.log(` branch : ${branch}`)
191
- console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
192
- console.log(` risk : ${o.risk}`)
193
- if (successorOf) console.log(` successor_of : ${successorOf.req_id} (부모 ${successorOf.parent_attempts_total}회 · replace 승인 확인)`)
194
- return
195
- }
196
-
197
- // 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지).
198
- // 단, gitignore 규칙이 없는 레거시 설치본도 기존 티켓의 순수 untracked 도구 산출물만 좁게 허용한다(D6·D7).
199
- const dirtyEntries = findReqNewDirtyEntries(git([...STATUS_Z_ARGS]), ticketRootRel)
200
- if (dirtyEntries.length > 0) {
201
- const dirty = dirtyEntries.map(formatStatusEntry).join('\n')
202
- throw new Error(
203
- `워킹트리가 clean이어야 req:new --run 가능:\n${dirty}\n` +
204
- `힌트: 방금 \`npx commitgate\`를 실행했다면 **설치분만** 먼저 커밋하십시오(\`git add -A\` 금지 —\n` +
205
- ` 무관한 변경·.env가 함께 커밋되고, 이어지는 req:review-codex가 그것을 외부로 전송합니다).\n` +
206
- ` 설치 출력의 "다음:" 안내가 stage할 정확한 경로 목록을 알려 줍니다.`,
207
- )
208
- }
209
- const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
210
- if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
211
-
212
- git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
213
- mkdirSync(ticketDir, { recursive: true })
214
- writeState(ticketDir, buildInitialState(reqId, branch, o.risk, successorOf))
215
- writeFileSync(join(ticketDir, dd.requirement), `# ${reqId} 요구사항\n\n${o.title ?? '(요구사항 작성)'}\n`, 'utf8')
216
- // DEC-WF-027 design-first: design·plan 스캐폴드도 함께 생성 — 첫 --kind design 리뷰가 문서 누락으로 fail-closed 되지 않게.
217
- writeFileSync(
218
- join(ticketDir, dd.design),
219
- `# ${reqId} 설계\n\n> 정본 결정은 SSOT(해당 DEC). 문서는 결정을 현재 코드/구조에 어떻게 반영할지 기록.\n\n## 현재 상태(변경 대상)\n\n## 핵심 설계 결정\n\n## Phase별 구현\n\n## 변경 파일\n\n## 하위호환·안전\n`,
220
- 'utf8',
221
- )
222
- writeFileSync(
223
- join(ticketDir, dd.plan),
224
- `# ${reqId} 계획 — phase 분해 (DEC-WF-027 §9.0)\n\n설계 승인 후 phase별 진행. **각 phase 후 Codex 리뷰·승인 → 다음.**\n\n> **Granularity 정책(REQ-2026-016 Phase C)**: phase 1개는 리뷰 가능한 크기로 — 코드 변경 ${cfg.granularityMaxFiles}파일 이하 권고. 초과 시 req:doctor가 D18 WARN(분할 권고·FAIL 아님). 큰 phase는 런타임 분할(예: B→B1/B2/B3)로 검수 면적을 줄인다.\n\n## Phase 1 — (제목) (\`phase-1-...\`)\n범위:\nExit: eslint0·typecheck0 · 단위 그린 · Codex phase 리뷰 승인.\n\n## 완료\n- 게이트 해당분(unit·typecheck·lint) · 사용자 main 머지(별도 승인).\n`,
225
- 'utf8',
226
- )
227
- writeFileSync(
228
- join(ticketDir, 'codex-request.md'),
229
- `# ${reqId} 리뷰 요청\n\n## 배경\n\n## 변경 요약\n\n## 리뷰 포인트\n`,
230
- 'utf8',
231
- )
232
- git(['add', ticketRel])
233
- git(['commit', '-m', `chore(req): ${reqId} 티켓 생성`])
234
-
235
- console.log(`[req:new] 생성 완료: ${reqId}`)
236
- console.log(` branch : ${branch} (체크아웃됨)`)
237
- console.log(` ticket : ${ticketRel}/ (스캐폴드 커밋)`)
238
- console.log(` 다음 : ${nextStepHint(cfg.packageManager, reqId)}`)
239
- }
240
-
241
- /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
242
- export function runCli(argv: string[]): void {
243
- try {
244
- main(argv)
245
- } catch (err) {
246
- console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
247
- process.exitCode = 1
248
- }
249
- }
250
-
251
- const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
252
- if (isMain) main()
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:new — AI REQ 워크플로우 1차 (단계 4A): REQ 티켓 + feat/req-* 브랜치 생성.
4
+ *
5
+ * 설계 근거: DEC-WF-020(D11) — REQ는 main에서 feat/req-* 브랜치로 시작한다.
6
+ * - state.json은 **BOM 없이** 생성(Node, review-codex의 writeState 재사용).
7
+ * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
+ * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
9
+ *
10
+ * 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."] [--successor-of <REQ-id>]
11
+ * --successor-of: 대체 REQ. 부모에 replace 종결(human-resolution) 기록이 있어야 하며, 없으면 fail-closed
12
+ * (티켓 미생성). lineage는 부모 state에서 읽는다(REQ-2026-029 A-2b).
13
+ */
14
+ import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
15
+ import { join, relative } from 'node:path'
16
+ import { pathToFileURL } from 'node:url'
17
+ import { writeState, loadState, resolveSuccessorLineage, type WorkflowState, type SuccessorOf } from './review-codex'
18
+ import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
19
+ import { createGitAdapter, type GitAdapter } from './lib/adapters'
20
+ import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
21
+ import { isToolOutputScratch } from './lib/scratch'
22
+
23
+ // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
24
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
25
+
26
+ function git(args: string[]): string {
27
+ return gitAdapter.exec(args)
28
+ }
29
+
30
+ /**
31
+ * `req:new` 전용 clean-tree 위반 목록(REQ-2026-012 D7·D8).
32
+ *
33
+ * 허용하는 것은 기존 티켓 직계의 **untracked 도구 산출물** 두 종류뿐이다. `state.json`, `responses/**`,
34
+ * staged-only 변경, rename/copy 등 그 밖의 모든 상태는 그대로 위반이다. review용
35
+ * `findUnstagedOrUntracked`는 staged-only(`M `)를 통과시키므로 여기서 재사용하지 않는다.
36
+ */
37
+ export function findReqNewDirtyEntries(rawStatusZ: string, ticketRoot: string): StatusEntry[] {
38
+ return parseStatusZ(rawStatusZ).filter((entry) => !isToolOutputScratch(entry, ticketRoot))
39
+ }
40
+
41
+ /** slug 검증: kebab-case(a-z0-9, '-' 구분). */
42
+ export function validateSlug(slug: string): void {
43
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
44
+ throw new Error(`slug는 kebab-case(a-z0-9, '-' 구분)여야 함: "${slug}"`)
45
+ }
46
+
47
+ /** REQ id 채번(순수): 같은 연도 기존 id의 max+1, 3자리 zero-pad. */
48
+ export function nextReqId(year: number, existingIds: string[]): string {
49
+ const prefix = `REQ-${year}-`
50
+ const maxN = existingIds
51
+ .filter((id) => id.startsWith(prefix))
52
+ .map((id) => Number.parseInt(id.slice(prefix.length), 10))
53
+ .filter((n) => Number.isFinite(n))
54
+ .reduce((a, b) => Math.max(a, b), 0)
55
+ return `${prefix}${String(maxN + 1).padStart(3, '0')}`
56
+ }
57
+
58
+ export function branchName(reqId: string, slug: string, branchPrefix: string): string {
59
+ return `${branchPrefix}${reqId.replace(/^REQ-/, '')}-${slug}`
60
+ }
61
+
62
+ /**
63
+ * `--run` 성공 후 다음 단계 안내(DEC-011-1). **config 로드 이후**이므로 pm별 실행 형식으로 파생한다.
64
+ *
65
+ * ⚠️ `DEFAULTS.packageManager`(= `'pnpm'`)를 폴백으로 쓰지 마라. `bin/init.ts`의 감지 폴백은 `'npm'`이라
66
+ * 두 값이 갈라져 있고, 그걸 문구에 끌어오면 npm 프로젝트가 pnpm 명령을 안내받는다 — 이 REQ가 고치는 결함이다.
67
+ * config가 없는 지점(헤더 주석·early throw)은 pm-중립 bare 표기를 쓴다.
68
+ */
69
+ export function nextStepHint(pm: PackageManager, reqId: string): string {
70
+ const id = reqId.replace(/^REQ-/, '')
71
+ return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
72
+ }
73
+
74
+ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH', successorOf?: SuccessorOf): WorkflowState {
75
+ return {
76
+ id: reqId,
77
+ branch,
78
+ phase: 'INTAKE',
79
+ risk_level: risk,
80
+ codex_thread_id: null,
81
+ review_base_sha: null,
82
+ review_diff_hash: null,
83
+ approved_diff_hash: null,
84
+ commit_allowed: false,
85
+ // DEC-WF-027 design-first / phase-gated 상태(Phase 2 도입). design 승인·phase 추적은 후속 단계에서 사용.
86
+ design_approved: false,
87
+ design_approved_hash: null,
88
+ current_phase: null,
89
+ phases: [],
90
+ // REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
91
+ approval_evidence_required: true,
92
+ // REQ-2026-048 DEC-4: design 증거 내구성 marker. **이 스캐폴드가 커밋되므로 HEAD blob에 영속**한다
93
+ // req:next의 DONE 게이트가 워킹 캐시가 아니라 커밋본을 읽어 신규/legacy를 판별한다(캐시 소실로 우회 불가).
94
+ evidence_durability_required: true,
95
+ // REQ-2026-027 D1: review series 모델 버전. 리뷰 전에도 존재해 "새 ticket(레코드 없음)"과
96
+ // "legacy(필드 부재)"를 구분한다. 필드 부재 = legacy → 새 재리뷰 시 AWAIT_HUMAN/throw(자동 초기화 금지).
97
+ review_series_model_version: 1,
98
+ // REQ-2026-029 D3: 대체 REQ면 부모 lineage(--successor-of). 빈 review_series로 새 예산 — 부모 이력만 보존.
99
+ ...(successorOf ? { successor_of: successorOf } : {}),
100
+ } as WorkflowState
101
+ }
102
+
103
+ function listExistingReqIds(workflowDir: string): string[] {
104
+ if (!existsSync(workflowDir)) return []
105
+ return readdirSync(workflowDir, { withFileTypes: true })
106
+ .filter((d) => d.isDirectory() && /^REQ-\d{4}-\d+$/.test(d.name))
107
+ .map((d) => d.name)
108
+ }
109
+
110
+ export interface Opts {
111
+ slug: string | null
112
+ risk: 'LOW' | 'HIGH'
113
+ title: string | null
114
+ run: boolean
115
+ root: string | null
116
+ successorOf: string | null // REQ-2026-029 D3: 부모 REQ id(대체 REQ lineage)
117
+ }
118
+
119
+ /** 인자 파싱(fail-closed): 잘못된 --risk·값 누락·알 없는 옵션은 즉시 throw(조용한 fallback 금지). */
120
+ export function parseArgs(argv: string[]): Opts {
121
+ const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null, successorOf: null }
122
+ for (let i = 0; i < argv.length; i++) {
123
+ const a = argv[i]
124
+ if (a === undefined) continue
125
+ // bare `--`는 POSIX end-of-options 마커(DEC-011-3). pnpm/yarn은 이를 스크립트에 그대로 넘긴다.
126
+ if (a === '--') {
127
+ continue
128
+ } else if (a === '--run') {
129
+ o.run = true
130
+ } else if (a === '--root') {
131
+ const v = argv[++i]
132
+ if (v === undefined) throw new Error('--root 값 필요')
133
+ o.root = v
134
+ } else if (a === '--successor-of') {
135
+ const v = argv[++i]
136
+ if (v === undefined) throw new Error('--successor-of 값 필요(부모 REQ id)')
137
+ o.successorOf = v
138
+ } else if (a === '--risk') {
139
+ const v = argv[++i]
140
+ if (v !== 'LOW' && v !== 'HIGH')
141
+ throw new Error(`--risk 값은 LOW 또는 HIGH여야 함 (받음: ${v ?? '(없음)'})`)
142
+ o.risk = v
143
+ } else if (a === '--title') {
144
+ const v = argv[++i]
145
+ if (v === undefined) throw new Error('--title 필요')
146
+ o.title = v
147
+ } else if (a.startsWith('-')) {
148
+ throw new Error(`알 수 없는 옵션: ${a}`)
149
+ } else {
150
+ o.slug = a
151
+ }
152
+ }
153
+ return o
154
+ }
155
+
156
+ export function main(argv: string[] = process.argv.slice(2)): void {
157
+ const o = parseArgs(argv)
158
+ // ⚠️ loadConfig 이전이라 pm을 모른다 → pm-중립 bare 표기(DEFAULTS 폴백 금지, DEC-011-1).
159
+ if (!o.slug) throw new Error('slug 필요 (예: req:new camera-hardfail --run)')
160
+ validateSlug(o.slug)
161
+
162
+ const cfg = loadConfig({ root: o.root })
163
+ gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
164
+ const dd: DesignDocs = cfg.designDocs
165
+
166
+ const year = new Date().getFullYear()
167
+ const reqId = nextReqId(year, listExistingReqIds(cfg.workflowDirAbs))
168
+ const branch = branchName(reqId, o.slug, cfg.branchPrefix)
169
+ const ticketDir = join(cfg.workflowDirAbs, reqId)
170
+ // config 문자열이 `.`·`workflow/.`처럼 같은 위치를 다르게 표현해도 Git이 내는 canonical repo-relative 경로와 맞춘다.
171
+ const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
172
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
173
+
174
+ // REQ-2026-029 D3: --successor-of lineage 해소. **branch 생성·mkdir 前**에 검증(design-r01 observation)
175
+ // 부모 없음·replace 기록 없음·형식 위반이면 여기서 throw해 티켓이 생성되지 않는다(R6 fail-closed).
176
+ let successorOf: SuccessorOf | undefined
177
+ if (o.successorOf !== null) {
178
+ const parentId = o.successorOf.startsWith('REQ-') ? o.successorOf : `REQ-${o.successorOf}`
179
+ const parentDir = join(cfg.workflowDirAbs, parentId)
180
+ let parentState: WorkflowState
181
+ try {
182
+ parentState = loadState(parentDir)
183
+ } catch {
184
+ throw new Error(`--successor-of ${parentId}: 부모 티켓 state를 읽을 수 없다(${parentDir})`)
185
+ }
186
+ // recorded_at은 자식 생성 시각(부모 값 아님). dry-run에선 검증만 하고 값은 버린다.
187
+ successorOf = resolveSuccessorLineage(parentState, parentId, new Date().toISOString())
188
+ }
189
+
190
+ if (!o.run) {
191
+ console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
192
+ console.log(` REQ : ${reqId}`)
193
+ console.log(` branch : ${branch}`)
194
+ console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
195
+ console.log(` risk : ${o.risk}`)
196
+ if (successorOf) console.log(` successor_of : ${successorOf.req_id} (부모 ${successorOf.parent_attempts_total}회 · replace 승인 확인)`)
197
+ return
198
+ }
199
+
200
+ // 클린 트리 요구( 브랜치 깨끗이 시작 — 의도 변경 섞임 방지).
201
+ // 단, gitignore 규칙이 없는 레거시 설치본도 기존 티켓의 순수 untracked 도구 산출물만 좁게 허용한다(D6·D7).
202
+ const dirtyEntries = findReqNewDirtyEntries(git([...STATUS_Z_ARGS]), ticketRootRel)
203
+ if (dirtyEntries.length > 0) {
204
+ const dirty = dirtyEntries.map(formatStatusEntry).join('\n')
205
+ throw new Error(
206
+ `워킹트리가 clean이어야 req:new --run 가능:\n${dirty}\n` +
207
+ `힌트: 방금 \`npx commitgate\`를 실행했다면 **설치분만** 먼저 커밋하십시오(\`git add -A\` 금지 —\n` +
208
+ ` 무관한 변경·.env가 함께 커밋되고, 이어지는 req:review-codex가 그것을 외부로 전송합니다).\n` +
209
+ ` 설치 출력의 "다음:" 안내가 stage할 정확한 경로 목록을 알려 줍니다.`,
210
+ )
211
+ }
212
+ const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
213
+ if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
214
+
215
+ git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
216
+ mkdirSync(ticketDir, { recursive: true })
217
+ writeState(ticketDir, buildInitialState(reqId, branch, o.risk, successorOf))
218
+ writeFileSync(join(ticketDir, dd.requirement), `# ${reqId} 요구사항\n\n${o.title ?? '(요구사항 작성)'}\n`, 'utf8')
219
+ // DEC-WF-027 design-first: design·plan 스캐폴드도 함께 생성 --kind design 리뷰가 문서 누락으로 fail-closed 되지 않게.
220
+ writeFileSync(
221
+ join(ticketDir, dd.design),
222
+ `# ${reqId} 설계\n\n> 정본 결정은 SSOT(해당 DEC). 본 문서는 그 결정을 현재 코드/구조에 어떻게 반영할지 기록.\n\n## 현재 상태(변경 대상)\n\n## 핵심 설계 결정\n\n## Phase별 구현\n\n## 변경 파일\n\n## 하위호환·안전\n`,
223
+ 'utf8',
224
+ )
225
+ writeFileSync(
226
+ join(ticketDir, dd.plan),
227
+ `# ${reqId} 계획 — phase 분해 (DEC-WF-027 §9.0)\n\n설계 승인 후 phase별 진행. **각 phase 후 Codex 리뷰·승인 → 다음.**\n\n> **Granularity 정책(REQ-2026-016 Phase C)**: phase 1개는 리뷰 가능한 크기로 — 코드 변경 ${cfg.granularityMaxFiles}파일 이하 권고. 초과 시 req:doctor가 D18 WARN(분할 권고·FAIL 아님). 큰 phase는 런타임 분할(예: B→B1/B2/B3)로 검수 면적을 줄인다.\n\n## Phase 1 — (제목) (\`phase-1-...\`)\n범위:\nExit: eslint0·typecheck0 · 단위 그린 · Codex phase 리뷰 승인.\n\n## 완료\n- 게이트 해당분(unit·typecheck·lint) · 사용자 main 머지(별도 승인).\n`,
228
+ 'utf8',
229
+ )
230
+ writeFileSync(
231
+ join(ticketDir, 'codex-request.md'),
232
+ `# ${reqId} 리뷰 요청\n\n## 배경\n\n## 변경 요약\n\n## 리뷰 포인트\n`,
233
+ 'utf8',
234
+ )
235
+ git(['add', ticketRel])
236
+ git(['commit', '-m', `chore(req): ${reqId} 티켓 생성`])
237
+
238
+ console.log(`[req:new] 생성 완료: ${reqId}`)
239
+ console.log(` branch : ${branch} (체크아웃됨)`)
240
+ console.log(` ticket : ${ticketRel}/ (스캐폴드 커밋)`)
241
+ console.log(` 다음 : ${nextStepHint(cfg.packageManager, reqId)}`)
242
+ }
243
+
244
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
245
+ export function runCli(argv: string[]): void {
246
+ try {
247
+ main(argv)
248
+ } catch (err) {
249
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
250
+ process.exitCode = 1
251
+ }
252
+ }
253
+
254
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
255
+ if (isMain) main()