commitgate 0.9.8 → 0.9.10

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