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,775 +1,813 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * req:next — 워크플로의 **다음 행동**을 상태에서 계산해 한 줄로 알려준다 (REQ-2026-010 phase-2).
4
- *
5
- * 존재 이유: "끊지 말고 끝까지 진행하라"를 에이전트의 기억에 맡기면 컨텍스트가 길어질수록 신뢰도가 떨어진다.
6
- * 다음 행동은 `state.json` + git 상태의 **결정론적 함수**이므로 도구가 계산한다. 에이전트 루프는 이렇게 짧아진다:
7
- * `req:next`를 실행 → 시키는 것을 하고 → 다시 `req:next`. `AWAIT_HUMAN`이면 그 문장 그대로 승인받기 전엔 멈춘다.
8
- *
9
- * ⚠️ **읽기 전용이다**(D6-1). 어떤 상태도 쓰지 않는다.
10
- * - `git write-tree` 금지 — object DB에 tree object를 쓴다. 그래서 바인딩 비교는 `captureIndexHash`(ls-files)로 한다.
11
- * - `git status`·`git diff --cached`는 stat cache 갱신으로 `.git/index`를 **다시 쓴다**. 모든 호출을
12
- * `--no-optional-locks`(= `GIT_OPTIONAL_LOCKS=0`의 CLI 등가물)로 감싼다.
13
- * - `createReadOnlyGit`이 allowlist를 **런타임에도** 강제한다(테스트뿐 아니라 실행 중에도).
14
- *
15
- * ⚠️ **강제(enforcement)가 아니라 자문(advisory)이다.** 승인 게이트는 여전히 `req:review-codex`/`req:commit`에 있다.
16
- * `req:next`가 틀려도 게이트는 뚫리지 않는다. 그래서 애매하면 `RUN` 쪽으로 기운다(fail-forward).
17
- *
18
- * 사용: req:next <REQ-id> [--json] [--root <path>] [--ticket <dir>] (저장소 패키지매니저의 실행 형식으로)
19
- */
20
- import { resolve, join, relative } from 'node:path'
21
- import { pathToFileURL } from 'node:url'
22
- import { loadConfig, buildScriptInvocation, type PackageManager } from './lib/config'
23
- import { createGitAdapter, type GitAdapter } from './lib/adapters'
24
- import { parseStatusZ, STATUS_Z_ARGS } from './lib/porcelain'
25
- import { reviewScratchPaths } from './lib/scratch'
26
- import {
27
- loadState,
28
- readPhases,
29
- captureDesignBinding,
30
- captureIndexHash,
31
- findUnstagedOrUntracked,
32
- isLegacyTicket,
33
- openSeriesAttempts,
34
- isSeriesKeyTerminal,
35
- type WorkflowState,
36
- type ReviewKind,
37
- type LastReviewMarker,
38
- } from './review-codex'
39
- import type { ReviewBudget, PhaseCommitPolicy } from './lib/config'
40
-
41
- // ─────────────────────────────────────────────── 읽기 전용 git 경계 (D6-1) ──
42
-
43
- type GitFn = (args: string[]) => string
44
-
45
- /** `req:next`가 호출해도 되는 git subcommand. 전부 무쓰기. */
46
- export const READONLY_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set(['rev-parse', 'status', 'diff', 'ls-files'])
47
-
48
- /**
49
- * argv에서 전역 플래그(`--no-optional-locks`, `-c <k=v>`, 기타 `-`로 시작)를 걷어낸 **첫 subcommand**.
50
- * 없으면 null.
51
- */
52
- export function gitSubcommand(args: string[]): string | null {
53
- for (let i = 0; i < args.length; i++) {
54
- const a = args[i]
55
- if (a === undefined) continue
56
- if (a === '-c') {
57
- i++ // -c 값을 하나 먹는다
58
- continue
59
- }
60
- if (a.startsWith('-')) continue
61
- return a
62
- }
63
- return null
64
- }
65
-
66
- /**
67
- * 읽기 전용 git 래퍼. 두 가지를 한다.
68
- * 1. 모든 호출 앞에 `--no-optional-locks`를 붙여 `.git/index` stat-cache 재기록을 막는다.
69
- * 2. allowlist subcommand(`write-tree`·`add`·`commit`·`reset` …)를 **실행 전에 throw**한다.
70
- *
71
- * (2)가 방어의 핵심이다 — 나중에 누가 무심코 `captureGitBinding`(write-tree) 끌어 쓰면 즉시 터진다.
72
- */
73
- export function createReadOnlyGit(adapter: GitAdapter): GitFn {
74
- return (args) => {
75
- const sub = gitSubcommand(args)
76
- if (sub === null || !READONLY_GIT_SUBCOMMANDS.has(sub))
77
- throw new Error(
78
- `req:next는 읽기 전용이다 — 허용되지 않은 git subcommand: ${sub ?? '(없음)'} (허용: ${[...READONLY_GIT_SUBCOMMANDS].join(', ')})`,
79
- )
80
- return adapter.exec(['--no-optional-locks', ...args])
81
- }
82
- }
83
-
84
- // ──────────────────────────────────────────────────────── 판정 결과 타입 ──
85
-
86
- export type NextKind = 'RUN' | 'AGENT' | 'AWAIT_HUMAN' | 'DONE' | 'BLOCKED'
87
-
88
- export interface NextAction {
89
- kind: NextKind
90
- /** 사람이 읽는 설명. 한 문장. */
91
- detail: string
92
- /** kind=RUN일 그대로 실행할 명령. */
93
- command?: string
94
- /** kind=AWAIT_HUMAN일 때 통제점 식별자. */
95
- controlPoint?: string
96
- /** kind=AWAIT_HUMAN일 때 **그 문장 그대로** 받아야 하는 승인 문장. */
97
- approvalSentence?: string
98
- /** kind=BLOCKED일 때 진단(state 덤프·검증 오류). */
99
- diagnostics?: string[]
100
- }
101
-
102
- /**
103
- * exit 계약. `RUN`/`AGENT`는 0(계속 진행 가능), `AWAIT_HUMAN`/`DONE`은 "루프를 멈춰라"라서 0과 구분한다.
104
- * `BLOCKED`=2는 `req:review-codex`의 blocked와 숫자를 맞춘다.
105
- *
106
- * ⚠️ `req:next`는 **CI 게이트가 아니다**. 판정 정본은 stdout(`--json`)의 `kind` 필드이고, exit code는 셸 루프 편의다.
107
- * CI가 10/11을 실패로 읽지 않도록 주의.
108
- */
109
- export const NEXT_EXIT_CODES: Record<NextKind, number> = {
110
- RUN: 0,
111
- AGENT: 0,
112
- BLOCKED: 2,
113
- AWAIT_HUMAN: 10,
114
- DONE: 11,
115
- }
116
-
117
- export function nextExitCode(kind: NextKind): number {
118
- return NEXT_EXIT_CODES[kind]
119
- }
120
-
121
- // ────────────────────────────────────────────────────── 순수 판정 코어 ──
122
-
123
- export interface NextInput {
124
- /** 후속 명령이 대상으로 삼을 티켓. `--ticket`으로 읽었으면 그대로 보존된다(R5). */
125
- target: NextTarget
126
- state: WorkflowState
127
- packageManager: PackageManager
128
- /** 설계 문서 3종이 git 인덱스에 전부 있는가. */
129
- designDocsInIndex: boolean
130
- /** 현재 설계문서 바인딩 해시. 계산 불가면 null. */
131
- currentDesignHash: string | null
132
- hasStagedChanges: boolean
133
- /** G1: `findUnstagedOrUntracked`가 비었는가(리뷰 가능한 워킹트리). */
134
- worktreeReviewClean: boolean
135
- /** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. */
136
- currentIndexHash: string | null
137
- /** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
138
- reviewBudget: ReviewBudget
139
- /**
140
- * REQ-2026-037: phase 자동 커밋 정책. main이 `cfg.phaseCommit.autoApprove`로 채운다(항상 존재 — DEFAULTS=never).
141
- * 필수 필드다(선택 아님): 해소는 config 계층에서 끝나므로 resolveNext는 내부 기본값을 두지 않는다.
142
- */
143
- phaseCommitAutoApprove: PhaseCommitPolicy
144
- }
145
-
146
- /** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
147
- function readConsumed(state: WorkflowState): { phase_id: string | null }[] {
148
- const raw = (state as { consumed_approvals?: unknown }).consumed_approvals
149
- if (!Array.isArray(raw)) return []
150
- return raw
151
- .filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
152
- .map((e) => ({ phase_id: typeof e.phase_id === 'string' ? e.phase_id : null }))
153
- }
154
-
155
- /**
156
- * 다음 대상 phase (REQ-2026-010 design R2).
157
- *
158
- * ⚠️ 진행도의 정본은 `consumed_approvals[].phase_id`이지 `phases[].approved`가 **아니다.**
159
- * `applyVerdict`는 승인 시 `approved`를 `true`로만 토글하고 미승인 시 되돌리지 않는다(sticky).
160
- * 그래서 "승인 → 코드 수정 → 재리뷰 NEEDS_FIX" 상태에서 `approved`로 세면 대상이 0개가 되어 판정이 무너진다.
161
- * `consumed_approvals`는 `req:commit`이 실제 커밋 시에만 append하는 append-only 원장이다.
162
- *
163
- * ⚠️ **전제: `phaseModelProblems(state)`가 비어 있어야 한다.** id가 중복이면 소비 1건이 같은 id의
164
- * 모든 항목을 소비 처리해 `null`(=전부 끝남)을 반환한다. `resolveNext`가 호출 전에 걸러 준다.
165
- */
166
- export function nextPhaseId(state: WorkflowState): string | null {
167
- const consumed = new Set(readConsumed(state).map((c) => c.phase_id).filter((p): p is string => p !== null))
168
- const pending = readPhases(state).find((p) => !consumed.has(p.id))
169
- return pending?.id ?? null
170
- }
171
-
172
- /**
173
- * `req:next`가 **렌더링하는 명령의 argv 토큰**에 허용되는 형식 (phase-2 R3/R4 P2).
174
- * `config.ts`의 designDocs basename 패턴과 같은 계약이며, 곳에 쓴다: `--phase <id>` 값, positional REQ id.
175
- *
176
- * - **선행 `-` 금지**: `review-codex`의 `parseArgs`는 `--phase` 값이 `-`로 시작하면 "값 누락"으로 throw하고,
177
- * positional로 오면 unknown option으로 죽는다.
178
- * - **공백·따옴표·세미콜론 금지**: `renderAction`이 argv를 `.join(' ')`로 렌더링하므로 argv 경계가 깨진다.
179
- * `state.id = 'REQ-2026-010 bad'`면 `... -- 2026-010 bad --kind phase ...`가 되어 `bad`가 REQ id로 읽힌다.
180
- *
181
- * `req:next`의 계약은 "다음 행동을 알려준다"가 아니라 **"실행 가능하고 옳은 다음 행동만 알려준다"**이다.
182
- * 렌더링할 수 없으면 `RUN`/`AWAIT_HUMAN`을 내지 않고 `BLOCKED`로 진단한다.
183
- */
184
- export const CLI_SAFE_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
185
-
186
- /** phase id는 argv 토큰이다. `CLI_SAFE_ARG_RE`와 같은 계약. */
187
- export const PHASE_ID_RE = CLI_SAFE_ARG_RE
188
-
189
- /** REQ id(`REQ-` 접두 제거 후)도 positional argv 토큰이다. */
190
- export const REQ_ID_RE = CLI_SAFE_ARG_RE
191
-
192
- /**
193
- * `--ticket <dir>` 값에서 **argv/셸 렌더링을 깨뜨리는** 문자 (phase-2 R6 P2).
194
- *
195
- * ⚠️ 화이트리스트를 쓰면 안 된다. 정상 경로가 전부 막힌다:
196
- * `D:\proj\workflow\REQ-2026-010`(콜론) · `/tmp/x/REQ-2026-010`(선행 `/`) · `./workflow/REQ-2026-010`(선행 `.`).
197
- * 그래서 **실제로 문제가 되는 문자만** 막는다: 공백류(`.join(' ')` 경계 파괴), 따옴표/백틱,
198
- * 명령 구분·치환·리다이렉트 메타문자.
199
- *
200
- * 선행 `-`는 별도로 막는다(옵션으로 파싱된다).
201
- * 참고: Windows 역슬래시 경로는 POSIX 셸에 그대로 붙여넣으면 이스케이프로 해석될 수 있다. `req:next`의
202
- * 출력은 **표시용**이고 실행 주체는 사람/에이전트이므로, 여기서는 argv 경계만 보장한다.
203
- */
204
- export const UNSAFE_CLI_PATH_CHARS = /[\s"'`$;&|<>()*?!#~^%{}[\]]/
205
-
206
- /** `--ticket <dir>` 값이 후속 명령에 그대로 실릴 수 있는지. */
207
- export function ticketPathProblems(ticketDir: string): string[] {
208
- if (ticketDir.trim() === '') return ['--ticket 경로가 비어 있다']
209
- if (ticketDir.startsWith('-'))
210
- return [`--ticket 경로가 '-'로 시작한다: ${JSON.stringify(ticketDir)} — 후속 명령에서 옵션으로 파싱된다.`]
211
- const bad = UNSAFE_CLI_PATH_CHARS.exec(ticketDir)
212
- if (bad)
213
- return [
214
- `--ticket 경로에 argv/셸을 깨뜨리는 문자가 있다: ${JSON.stringify(bad[0])} in ${JSON.stringify(ticketDir)} — 공백·따옴표·명령 구분자는 쓸 수 없다.`,
215
- ]
216
- return []
217
- }
218
-
219
- /**
220
- * 후속 명령이 대상으로 삼을 티켓 (phase-2 R5 P2).
221
- *
222
- * ⚠️ **`reqId` 문자열만으로는 부족하다.** `req:next --ticket <dir>`로 비표준 위치의 티켓을 읽고
223
- * `req:review-codex -- <reqId>`를 지시하면, 그 명령은 **기본 위치**(`workflow/REQ-<id>`)를 리뷰한다.
224
- * 방금 판정한 티켓이 아니다. 그래서 "어떻게 지목했는가"를 그대로 보존해 명령에 되돌려 준다.
225
- */
226
- export type NextTarget = { kind: 'req'; reqId: string } | { kind: 'ticket'; ticketDir: string }
227
-
228
- /** 후속 명령에 붙일 target argv. */
229
- function targetArgs(t: NextTarget): string[] {
230
- return t.kind === 'req' ? [t.reqId] : ['--ticket', t.ticketDir]
231
- }
232
-
233
- /**
234
- * target이 후속 명령에 안전하고 **실제로 방금 판정한 티켓을 가리키는지** 검증한다 (phase-2 R5 P2).
235
- *
236
- * `kind: 'req'`에서 **identity 검증**이 핵심이다. `main()`이 `workflow/REQ-2026-010/state.json`을 읽었는데
237
- * 그 안의 `id`가 `REQ-2026-999`면, argv-safe하다는 이유로 통과시켜선 안 된다 — 렌더링한 명령이
238
- * **다른 티켓**을 대상으로 한다. 이 경우는 state 손상이므로 `BLOCKED`.
239
- */
240
- export function targetProblems(target: NextTarget, state: WorkflowState): string[] {
241
- if (target.kind === 'ticket') return ticketPathProblems(target.ticketDir)
242
- const problems = reqIdProblems(target.reqId)
243
- if (problems.length) return problems
244
- const expected = `REQ-${target.reqId}`
245
- if (state.id !== expected)
246
- return [
247
- `state.json의 id(${JSON.stringify(state.id)})가 요청한 티켓(${expected})과 다르다 후속 명령이 다른 티켓을 대상으로 하게 된다. state.json을 확인하라.`,
248
- ]
249
- return []
250
- }
251
-
252
- /**
253
- * `phases`를 진행도 계산에 쓸 수 있는지 검사한다 (phase-2 R1/R2/R3 P2). 문제가 있으면 사유 목록, 없으면 빈 배열.
254
- *
255
- * 다 **조용한 오판정**으로 이어지는 같은 실패 class다.
256
- *
257
- * 1. **배열이 아님**(`phases: {}` / `null`): `Array.isArray` 실패 `rawLen=0` **레거시로 오분류**되어
258
- * 소비 이력만 있으면 `DONE`이 나온다.
259
- * 2. **malformed 항목**: `readPhases`가 `{id: string}`이 아닌 항목을 걸러내므로 배열이 비어 보이고
260
- * `pending=null`이 된다(그런데 `rawLen>0`이라 레거시 분기로도 안 간다) → 조용한 `DONE`.
261
- * 3. **빈 id**(`id: ''`): `readPhases`는 통과시키지만 `--phase` 인자로 쓸 수 없다. `reviewCmd`가 `--phase`를
262
- * 빠뜨린 명령을 지시하고, `review-codex`의 `resolvePhaseTarget`이 "대상 모호"로 죽는다.
263
- * 4. **CLI-불안전 id**(`--bad`, 공백 포함): `req:next`가 **실행 불가능한 `RUN`**을 지시한다.
264
- * `--phase --bad`는 `parseArgs`가 값 누락으로 throw하고, 공백은 `.join(' ')` 렌더링에서 argv를 깬다.
265
- * 5. **중복 id**: `consumed_approvals`에 `p1` 1건만 있어도 `phases=[p1, p1]` 두 항목이 모두 소비 처리된다.
266
- *
267
- * 판정 불가면 조용히 넘어가지 않고 `BLOCKED`(fail-closed). state는 사람이 고쳐야 한다.
268
- * ⚠️ `phases` **부재**와 **빈 배열**만이 정상적인 "여기서 판단하지 않음"이다(레거시 또는 미분해).
269
- */
270
- export function phaseModelProblems(state: WorkflowState): string[] {
271
- const raw = (state as { phases?: unknown }).phases
272
- if (raw === undefined) return [] // 부재 = 레거시. 다른 분기가 처리한다.
273
- if (!Array.isArray(raw))
274
- return [`phases가 배열이 아니다(${raw === null ? 'null' : typeof raw}) 레거시로 오분류되어 조용히 DONE이 될 수 있다`]
275
- if (raw.length === 0) return [] // 배열 = 레거시 또는 미분해.
276
-
277
- const problems: string[] = []
278
- const parsed = readPhases(state)
279
- if (parsed.length !== raw.length)
280
- problems.push(`phases[]에 형식이 잘못된 항목 ${raw.length - parsed.length}개(문자열 id 필요) 진행도를 셀 수 없다`)
281
-
282
- const empty = parsed.filter((p) => p.id.trim() === '').length
283
- if (empty) problems.push(`phases[].id가 비어 있는 항목 ${empty}개 — \`--phase\` 인자로 쓸 수 없다`)
284
-
285
- const unsafe = parsed.filter((p) => p.id.trim() !== '' && !PHASE_ID_RE.test(p.id)).map((p) => JSON.stringify(p.id))
286
- if (unsafe.length)
287
- problems.push(
288
- `phases[].id가 CLI 인자로 안전하지 않다: ${unsafe.join(', ')} — ${String(PHASE_ID_RE)} 형식이어야 한다(선행 '-'는 --phase 값 누락으로, 공백은 argv 깨짐으로 이어진다)`,
289
- )
290
-
291
- const seen = new Set<string>()
292
- const dup = new Set<string>()
293
- for (const p of parsed) {
294
- if (seen.has(p.id)) dup.add(p.id)
295
- seen.add(p.id)
296
- }
297
- if (dup.size) problems.push(`phases[].id 중복: ${[...dup].join(', ')} — 소비 1건이 같은 id의 모든 항목을 소비 처리한다`)
298
-
299
- return problems
300
- }
301
-
302
- /**
303
- * 렌더링할 명령의 positional REQ id가 argv-안전한지 (phase-2 R4 P2).
304
- *
305
- * `main()`은 CLI 인자가 아니라 **`state.id`에서 파생한** reqId를 쓴다(`state.id.replace(/^REQ-/, '')`).
306
- * 그래서 `state.json`이 손상되면 `req:next`가 **다른 티켓을 대상으로 하는 명령**을 지시할 수 있다:
307
- * `state.id = 'REQ-2026-010 bad'` `... -- 2026-010 bad --kind phase ...` → `bad`가 REQ id로 읽힌다.
308
- * `state.id = 'REQ---bad'` → `... -- --bad ...` → unknown option으로 죽는다.
309
- */
310
- export function reqIdProblems(reqId: string): string[] {
311
- if (REQ_ID_RE.test(reqId)) return []
312
- return [
313
- `REQ id가 CLI 인자로 안전하지 않다: ${JSON.stringify(reqId)} ${String(REQ_ID_RE)} 형식이어야 한다. state.json의 \`id\`를 확인하라.`,
314
- ]
315
- }
316
-
317
- /**
318
- * `phaseId === null`은 **레거시**(phase 미추적)라 `--phase`를 붙이지 않는 것이 옳다.
319
- * 빈 문자열 id는 여기 도달할 수 없다 — `phaseModelProblems`의 0번 분기가 먼저 `BLOCKED`로 막는다.
320
- * (도달했다면 `--phase` 없는 명령이 나가고 `resolvePhaseTarget`이 "대상 모호"로 죽는다.)
321
- */
322
- function reviewCmd(pm: PackageManager, target: NextTarget, kind: ReviewKind, phaseId: string | null): string {
323
- const args = [...targetArgs(target), '--kind', kind]
324
- if (phaseId !== null) args.push('--phase', phaseId)
325
- args.push('--run')
326
- return buildScriptInvocation(pm, 'req:review-codex', args).join(' ')
327
- }
328
-
329
- function commitCmd(pm: PackageManager, target: NextTarget): string {
330
- return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run']).join(' ')
331
- }
332
-
333
- /**
334
- * REQ-2026-037: 자동 커밋 RUN 명령. `commitCmd`와 달리 `-m "<메시지>"` 자리표시자를 싣는다 — `req:commit`은
335
- * 메시지 없이는 fail-closed로 죽기 때문(read-only인 req:next는 메시지를 합성할 수 없다). 에이전트가 이
336
- * 자리표시자를 실제 conventional 메시지로 바꿔 실행한다(AGENT 단계에서 `git add` 대상을 고르는 것과 동형).
337
- */
338
- function autoCommitCmd(pm: PackageManager, target: NextTarget): string {
339
- return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run', '-m', '"<이 phase의 conventional 커밋 메시지>"']).join(' ')
340
- }
341
-
342
- /**
343
- * REQ-2026-037: 부분 커밋(source 커밋 후 consume 전) 복구 명령. `req:commit --finalize --run`은 source를
344
- * 재커밋하지 않고 evidence/consume만 복구한다 복구 가드가 안내하는 정확한 명령(detail·command·approvalSentence 일관).
345
- */
346
- function finalizeCmd(pm: PackageManager, target: NextTarget): string {
347
- return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--finalize', '--run']).join(' ')
348
- }
349
-
350
- /**
351
- * 대상(REQ id 또는 `--ticket`) 미지정 에러 문구(DEC-011-1). **config 로드 이후**라 pm별로 파생한다.
352
- * 리터럴을 박으면 다른 pm 프로젝트의 사용자가 그대로 따라 할 수 없는 명령을 안내받는다.
353
- */
354
- export function missingTargetHint(pm: PackageManager): string {
355
- return `REQ id 또는 --ticket <dir> 필요 (예: ${buildScriptInvocation(pm, 'req:next', ['2026-010']).join(' ')})`
356
- }
357
-
358
- interface RunCandidate {
359
- command: string
360
- kind: ReviewKind
361
- phaseId: string | null
362
- /** 리뷰가 바인딩할 대상의 현재 해시. null이면 비교 불가 G2 통과(fail-forward). */
363
- compareHash: string | null
364
- detail: string
365
- }
366
-
367
- /**
368
- * `RUN` 후보에 두 게이트를 적용한다. 통과 못 하면 다른 kind로 강등한다.
369
- *
370
- * **G1 (D10 전제)**: `review-codex`의 `main()`은 호출 전 워킹트리가 staged+스크래치뿐인지 검사해
371
- * 아니면 throw한다. 그걸 모른 채 `RUN`을 지시하면 그 명령은 즉시 죽는다.
372
- *
373
- * **G2 (바인딩 신선도, outcome-aware)**: `last_review`가 같은 `(kind, phase_id)` + 같은 `compare_hash`면
374
- * 직전 리뷰가 이미 이 바인딩을 봤다는 뜻이다. NEEDS_FIX 후에도 staged는 남으므로, 이걸 안 보면
375
- * 같은 바인딩을 무한 재리뷰한다(`blocked_review` 회로차단기는 BLOCKED만 잡고 NEEDS_FIX는 못 잡는다).
376
- */
377
- function gateRunCandidate(input: NextInput, cand: RunCandidate): NextAction {
378
- // G1
379
- if (!input.worktreeReviewClean)
380
- return {
381
- kind: 'AGENT',
382
- detail:
383
- '워킹트리에 unstaged/untracked 변경이 있어 리뷰(D10)가 실패한다. 의도한 변경은 `git add`, 외는 정리한 다시 req:next.',
384
- }
385
-
386
- // terminal (REQ-2026-029 A-2b): human-resolution으로 종결된 키 → AWAIT_HUMAN. **G3보다 앞**(R4) — 종결된
387
- // series는 예산 안내("고치고 예외 받으라") 아니라 "이미 끝났다 — 대체 REQ" 안내가 맞다. G1보다는 뒤
388
- // (dirty면 정리 먼저). 우선순위: G1 → terminal → G3 → G2.
389
- if (isSeriesKeyTerminal(input.state, cand.kind, cand.phaseId))
390
- return {
391
- kind: 'AWAIT_HUMAN',
392
- detail: '이 series는 human-resolution으로 종결됐다. 같은 키에서 자동으로 재개하지 않는다.',
393
- controlPoint: 'human-resolution 종결됨',
394
- approvalSentence: '대체가 필요하면 `req:new --successor-of <이 REQ>`로 만든다(종결 상태 유지 결정도 사람이 한다)',
395
- diagnostics: ['종결 사유: human-resolution', '재개는 자동으로 일어나지 않는다 — 대체 REQ 또는 종결 유지.'],
396
- }
397
-
398
- // G3 (REQ-2026-028 A-2a): 자동 예산 소진(escalated) AWAIT_HUMAN. **G2보다 앞**(R13) 5회차 NEEDS_FIX
399
- // 직후엔 escalated와 같은 바인딩 needs-fix가 동시 성립하는데, G2가 먼저 "findings 고치고 다시 add"(AGENT)
400
- // 내면 그 조언이 거짓이다(고쳐도 사람 승인 없이 6회차가 안 열린다). escalated는 파생값(저장 안 함, R11).
401
- const openAttempts = openSeriesAttempts(input.state, cand.kind, cand.phaseId)
402
- const { autoBudget, hardCap } = input.reviewBudget
403
- if (openAttempts >= autoBudget) {
404
- const nextAttempt = openAttempts + 1
405
- const lrOutcome = (input.state.last_review as LastReviewMarker | undefined)?.outcome ?? '(없음)'
406
- const hardBlocked = openAttempts >= hardCap
407
- // ⚠️ "위험 수용"은 어느 문구에도 넣지 않는다(배분표 ④ — 부정문으로도 금지). 긍정 선택지만 나열.
408
- const options = hardBlocked
409
- ? '예외로도 진행 불가 종료하거나 정합한 대체 REQ 작성한다.'
410
- : '사람 승인 1회 예외 가능(review_exception_confirmed) · 종료 · 정합한 대체 REQ 작성.'
411
- return {
412
- kind: 'AWAIT_HUMAN',
413
- detail: hardBlocked
414
- ? `이 series는 하드 상한(hardCap=${hardCap})에 도달했다. ${nextAttempt}회차는 어떤 경로로도 실행하지 않는다.`
415
- : `이 series는 자동 예산(autoBudget=${autoBudget})을 소진했다. ${nextAttempt}회차는 사람 결정이 필요하다.`,
416
- controlPoint: 'review 예산 소진(escalated)',
417
- approvalSentence: hardBlocked
418
- ? 'review 하드 상한 도달 — 종료 또는 대체 REQ 작성(둘 중 하나를 사람이 결정)'
419
- : `review ${nextAttempt}회차 예외 승인(또는 종료·대체 REQ 작성)`,
420
- diagnostics: [
421
- `series 시도 수(openAttempts)=${openAttempts} · 다음 회차=${nextAttempt}`,
422
- `직전 리뷰 outcome=${lrOutcome}`,
423
- `선택지: ${options}`,
424
- ],
425
- }
426
- }
427
-
428
- // G2
429
- const lr = input.state.last_review as LastReviewMarker | undefined
430
- const sameTarget =
431
- !!lr &&
432
- lr.review_kind === cand.kind &&
433
- (lr.phase_id ?? null) === cand.phaseId &&
434
- typeof lr.compare_hash === 'string' &&
435
- cand.compareHash !== null &&
436
- lr.compare_hash === cand.compareHash
437
-
438
- if (sameTarget && lr) {
439
- switch (lr.outcome) {
440
- case 'needs-fix':
441
- return {
442
- kind: 'AGENT',
443
- detail: `직전 리뷰가 바인딩을 보고 NEEDS_FIX를 냈다. findings를 수정하고 \`git add\` 후 다시 req:next. (같은 바인딩 재리뷰는 낭비)`,
444
- }
445
- case 'blocked':
446
- return {
447
- kind: 'BLOCKED',
448
- detail:
449
- '직전 리뷰가 이 바인딩에서 BLOCKED(지적 없이 미승인)였다. 같은 리뷰를 재시도하지 말 것 — 리뷰 대상을 바꾸거나 사람이 판단한다.',
450
- diagnostics: [
451
- 'AGENTS.md §3: BLOCKED(exit 2)는 같은 리뷰 재시도 금지.',
452
- '스레드 고착이 의심되면 사람이 `--fresh-thread`로 1회만 회복을 시도할 수 있다(req:next는 자동으로 지시하지 않는다 — 회로차단기가 무력화된다).',
453
- ],
454
- }
455
- case 'invalid':
456
- if (lr.count >= 2)
457
- return {
458
- kind: 'BLOCKED',
459
- detail: `같은 바인딩에서 리뷰 응답이 ${lr.count}회 연속 무효(구조/도메인 검증 실패)다. 도구·스키마 문제로 보고 사람에게 보고한다.`,
460
- diagnostics: lr.errors.length ? lr.errors : ['(저장된 검증 오류 없음)'],
461
- }
462
- return { kind: 'RUN', detail: `${cand.detail} (직전 응답이 무효였다1회 재시도)`, command: cand.command }
463
- case 'approved':
464
- return {
465
- kind: 'BLOCKED',
466
- detail:
467
- '방어적 차단: 이 바인딩은 이미 승인됐는데 승인 상태가 state에 보이지 않는다. state가 손상됐을 수 있다.',
468
- diagnostics: [`last_review=${JSON.stringify(lr)}`, `commit_allowed=${String(input.state.commit_allowed)}`],
469
- }
470
- default:
471
- break // 알 수 없는 outcome → fail-forward
472
- }
473
- }
474
-
475
- return { kind: 'RUN', detail: cand.detail, command: cand.command }
476
- }
477
-
478
- /**
479
- * 다음 행동 판정(순수). 먼저 매치되는 분기가 이긴다.
480
- *
481
- * ⚠️ `blocked_review`를 **읽지 않는다**(design R5 P2). 그 마커의 `review_binding`은 phase에서 tree OID라
482
- * `req:next`가 재계산할 수 없어 "현재 바인딩에 대한 것인가"를 판정할 수 없다. stale 마커로 영구히 막히는
483
- * 것보다, G2(`last_review.compare_hash`)로 바인딩 변경을 정확히 감지하는 편이 맞다. 회로차단기의 **강제**는
484
- * `review-codex`의 `shouldShortCircuitBlockedReview`에 그대로 남아 있다(codex 호출 없이 exit 2).
485
- */
486
- export function resolveNext(input: NextInput): NextAction {
487
- const { state, packageManager: pm, target } = input
488
-
489
- // 0. state를 신뢰할 없으면 **아무 판정도 하지 않는다**(phase-2 R1/R2/R3/R4 P2).
490
- // - reqId/phase id가 argv-불안전하면 렌더링한 명령이 실행 불가능하거나 **엉뚱한 티켓**을 대상으로 한다.
491
- // - phases[]가 손상되면 nextPhaseId가 null을 반환해 조용한 DONE으로 이어진다.
492
- // 살아 있는 승인(1번)보다도 먼저 막는다 손상된 state에서 "커밋을 승인하라" 말하면 엉뚱한 phase가 소비된다.
493
- const modelProblems = [...targetProblems(target, state), ...phaseModelProblems(state)]
494
- if (modelProblems.length)
495
- return {
496
- kind: 'BLOCKED',
497
- detail: 'state.json을 신뢰할 없어 다음 행동을 판정하지 않는다. 사람이 state를 고쳐야 한다.',
498
- diagnostics: modelProblems,
499
- }
500
-
501
- // 1. 살아 있는 승인이 가장 쉽게 상한다 — 다른 어떤 행동도 D9(staged tree == approved tree)를 깨뜨린다.
502
- if (state.commit_allowed === true) {
503
- // REQ-2026-037: opt-in 자동 커밋. **fail-closed** — `risk_level==='LOW'` 정확 일치 AND 정책 low-only AND
504
- // staged 존재일 때만 RUN(자동 커밋). "HIGH가 아님"이 "자동 안전"을 의미하지 않는다: 누락·`'Low'` 오타·
505
- // 손상·HIGH는 전부 else(AWAIT_HUMAN)로 떨어지고, HIGH는 req-commit의 Gate B가 이중 백스톱.
506
- const autoCommit =
507
- input.phaseCommitAutoApprove === 'low-only' && state.risk_level === 'LOW' && input.hasStagedChanges
508
- if (autoCommit)
509
- return {
510
- kind: 'RUN',
511
- detail: 'phase 승인이 살아 있다(LOW · 자동 커밋). phase의 conventional 커밋 메시지를 작성해 실행하라.',
512
- command: autoCommitCmd(pm, target),
513
- }
514
- // 복구 가드(R4): 승인이 살아 있는데 staged비었으면 부분 커밋(source 커밋 consume 전)일 수 있다.
515
- // 정상 커밋을 지시하면 req:commit `staged 변경 없음`으로 죽어 자동 루프가 스핀한다 → --finalize로 복구.
516
- // detail·command·controlPoint·approvalSentence를 모두 finalize로 맞춘다(phase-2 리뷰 observation).
517
- if (!input.hasStagedChanges)
518
- return {
519
- kind: 'AWAIT_HUMAN',
520
- detail: 'phase 승인이 살아 있으나 staged가 비었다 — 부분 커밋일 수 있다. `req:commit --finalize --run`으로 복구가 필요하다.',
521
- command: finalizeCmd(pm, target),
522
- controlPoint: 'req:commit --finalize --run 직전',
523
- approvalSentence: 'req:commit --finalize --run 승인',
524
- }
525
- return {
526
- kind: 'AWAIT_HUMAN',
527
- detail: 'phase 승인이 살아 있다. 커밋 전 사람 확인이 필요하다.',
528
- command: commitCmd(pm, target),
529
- controlPoint: 'req:commit --run 직전',
530
- approvalSentence: 'req:commit --run 승인',
531
- }
532
- }
533
-
534
- // 1.5 legacy ticket(REQ-2026-027 D1): 모델 버전 부재 = legacy. 살아 있는 승인(1번)보다는 뒤 —
535
- // 그건 소비만 하면 되고 새 외부 호출이 아니다. design/phase RUN 후보(2·3번)보다는 **앞** — 그 후보를
536
- // 내면 사용자가 실행한 뒤에야 호출 지점에서 throw된다(R2는 AWAIT_HUMAN을 요구). 자동 초기화하지 않는다.
537
- if (isLegacyTicket(state))
538
- return {
539
- kind: 'AWAIT_HUMAN',
540
- detail:
541
- 'legacy ticket(review_series_model_version 부재)이다. 자동으로 새 모델로 초기화하지 않는다 — 사람이 이 티켓을 새 series 모델로 채택할지 결정해야 한다.',
542
- controlPoint: 'legacy 티켓 채택',
543
- approvalSentence: 'state.json에 review_series_model_version: 1 추가(이 티켓을 새 모델로 채택) 승인',
544
- }
545
-
546
- // 2. 설계 문서가 인덱스에 없으면 3번의 freshness 판정(captureDesignBinding) throw한다. 여기서 먼저 거른다.
547
- if (!input.designDocsInIndex)
548
- return {
549
- kind: 'AGENT',
550
- detail: '설계 문서 00/01/02가 git 인덱스에 없다. 작성한 뒤 `git add` 하고 다시 req:next.',
551
- }
552
-
553
- // 3. design 미승인 또는 stale(문서가 승인 이후 바뀜).
554
- const designApprovedHash = typeof state.design_approved_hash === 'string' ? state.design_approved_hash : null
555
- const designValid =
556
- state.design_approved === true && designApprovedHash !== null && designApprovedHash === input.currentDesignHash
557
- if (!designValid)
558
- return gateRunCandidate(input, {
559
- command: reviewCmd(pm, target, 'design', null),
560
- kind: 'design',
561
- phaseId: null,
562
- compareHash: input.currentDesignHash,
563
- detail: state.design_approved === true ? '설계 문서가 승인 이후 변경됐다(stale). 재승인이 필요하다.' : '설계 승인이 필요하다.',
564
- })
565
-
566
- const rawPhases = (state as { phases?: unknown }).phases
567
- const rawLen = Array.isArray(rawPhases) ? rawPhases.length : 0
568
- const consumed = readConsumed(state)
569
-
570
- if (rawLen === 0) {
571
- // 4. 신규 티켓(req:new이 approval_evidence_required=true를 심는다) — 아직 phase를 안 나눴다.
572
- if (state.approval_evidence_required === true)
573
- return {
574
- kind: 'AGENT',
575
- detail: '`02-plan.md`에 phase를 분해하고 `state.json`의 `phases[]`를 채운 뒤 다시 req:next.',
576
- }
577
-
578
- // 5~7. 레거시 티켓(필드 자체가 없음 — phase 추적 없이 리뷰하던 시절).
579
- if (!('approval_evidence_required' in state))
580
- return resolveLegacy(input, consumed)
581
-
582
- return {
583
- kind: 'BLOCKED',
584
- detail: 'phases[]가 비었는데 approval_evidence_required가 true도 아니고 부재도 아니다 — 신규/레거시를 구분할 수 없다.',
585
- diagnostics: [`approval_evidence_required=${JSON.stringify(state.approval_evidence_required)}`],
586
- }
587
- }
588
-
589
- // 8~10. phase 추적 티켓.
590
- const pending = nextPhaseId(state)
591
- if (pending !== null) {
592
- if (!input.hasStagedChanges)
593
- return { kind: 'AGENT', detail: `phase \`${pending}\`를 구현하고 테스트를 통과시킨 뒤 \`git add\` 하고 다시 req:next.` }
594
- return gateRunCandidate(input, {
595
- command: reviewCmd(pm, target, 'phase', pending),
596
- kind: 'phase',
597
- phaseId: pending,
598
- compareHash: input.currentIndexHash,
599
- detail: `phase \`${pending}\`의 staged 변경을 리뷰받는다.`,
600
- })
601
- }
602
-
603
- if (input.worktreeReviewClean && !input.hasStagedChanges) {
604
- // REQ-2026-037 R5: 자동 커밋(low-only)에선 매 phase 정지가 없으므로, 병합 전 **단일** 사람 확인을
605
- // 종단에서 실체화한다 — DONE(exit 11)이 아니라 AWAIT_HUMAN(exit 10)으로 루프를 확실히 멈춘다.
606
- // 승인 문장은 새로 만들지 않고 계약 통제점표(I1/I2/B1)의 정본을 가리킨다(design-r01 observation).
607
- if (input.phaseCommitAutoApprove === 'low-only')
608
- return {
609
- kind: 'AWAIT_HUMAN',
610
- detail:
611
- '모든 phase가 자동 커밋됐다. feature→main 통합은 사람 승인이 필요하다 — 경로(PR 또는 direct push)와 승인 문장은 AGENTS.md 통제점표(I1/I2/B1)를 따른다.',
612
- controlPoint: '통합(feature→main)',
613
- approvalSentence:
614
- '통합 경로를 택하고 통제점의 정본 승인 문장을 받는다 [I1] feature branch push + PR 생성 승인 → [I2] required checks green 확인 후 PR merge 승인, 또는 [B1] branch protection bypass를 사용한 direct push 승인',
615
- }
616
- // never(기본): 현행 그대로 DONE 기존 사용자 무회귀.
617
- return {
618
- kind: 'DONE',
619
- detail:
620
- '모든 phase가 승인·커밋됐다. 다음은 통합 통제점 — `[I1]` PR 생성 또는 `[B1]` protected branch direct push. 경로 선택과 승인은 사람이 한다.',
621
- }
622
- }
623
-
624
- return {
625
- kind: 'BLOCKED',
626
- detail: '모든 phase가 소비됐는데 워킹트리가 깨끗하지 않다. 남은 변경이 이 티켓 범위인지 사람이 판단해야 한다.',
627
- diagnostics: [
628
- `hasStagedChanges=${String(input.hasStagedChanges)}`,
629
- `worktreeReviewClean=${String(input.worktreeReviewClean)}`,
630
- `phases=${readPhases(state).map((p) => p.id).join(', ') || '(없음)'}`,
631
- `consumed=${consumed.map((c) => c.phase_id ?? '(null)').join(', ') || '(없음)'}`,
632
- ],
633
- }
634
- }
635
-
636
- /**
637
- * 레거시 티켓(`phases[]` 없음 + `approval_evidence_required` 필드 부재).
638
- *
639
- * `phases[]`가 없어 "전부 consumed"가 vacuous truth가 되므로, 소비 이력으로만 완료를 판정한다(design R2 P2-A).
640
- * 남은 phase가 있는지는 **도구가 알 수 없다** — `DONE`의 detail이 그 사실을 말한다. 조용히 "다 끝났다"고 하지 않는다.
641
- */
642
- function resolveLegacy(input: NextInput, consumed: { phase_id: string | null }[]): NextAction {
643
- const { state, packageManager: pm, target } = input
644
-
645
- if (input.hasStagedChanges)
646
- return gateRunCandidate(input, {
647
- command: reviewCmd(pm, target, 'phase', null),
648
- kind: 'phase',
649
- phaseId: null,
650
- compareHash: input.currentIndexHash,
651
- detail: '레거시 티켓(phase 미추적)의 staged 변경을 리뷰받는다.',
652
- })
653
-
654
- if (consumed.length === 0)
655
- return { kind: 'AGENT', detail: '구현하고 테스트를 통과시킨 뒤 `git add` 하고 다시 req:next.' }
656
-
657
- if (input.worktreeReviewClean)
658
- return {
659
- kind: 'DONE',
660
- detail:
661
- '레거시 티켓(phases[] 미추적) 승인·커밋 이력이 있고 워킹트리가 깨끗하다. **남은 phase 여부는 도구가 알 수 없다**: `02-plan.md`를 확인하라. 통합은 `[I1]`/`[B1]` 통제점.',
662
- }
663
-
664
- return {
665
- kind: 'BLOCKED',
666
- detail: '레거시 티켓에 소비 이력이 있지만 워킹트리가 깨끗하지 않다. 남은 변경을 사람이 판단해야 한다.',
667
- diagnostics: [`consumed=${consumed.length}건`, `worktreeReviewClean=false`],
668
- }
669
- }
670
-
671
- // ──────────────────────────────────────────────────────────────── CLI ──
672
-
673
- export interface Opts {
674
- reqId: string | null
675
- ticket: string | null
676
- root: string | null
677
- json: boolean
678
- }
679
-
680
- export function parseArgs(argv: string[]): Opts {
681
- const o: Opts = { reqId: null, ticket: null, root: null, json: false }
682
- for (let i = 0; i < argv.length; i++) {
683
- const a = argv[i]
684
- if (a === undefined) continue
685
- // bare `--`는 옵션이 아니라 POSIX end-of-options 마커다(DEC-011-3). npm은 `npm run x -- a`에서
686
- // 이를 제거하지만 pnpm/yarn은 그대로 넘긴다 흡수한다. 이후 인자도 계속 옵션으로 파싱한다
687
- // (전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run이 된다).
688
- if (a === '--') continue
689
- else if (a === '--json') o.json = true
690
- else if (a === '--ticket') o.ticket = argv[++i] ?? null
691
- else if (a === '--root') {
692
- const v = argv[++i]
693
- if (v === undefined) throw new Error('--root 값 필요')
694
- o.root = v
695
- } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
696
- else o.reqId = a
697
- }
698
- return o
699
- }
700
-
701
- /** 사람이 읽는 출력. `displayId`는 표시 전용(argv가 아니다) — `state.id`를 그대로 쓴다. */
702
- export function renderAction(displayId: string, a: NextAction): string {
703
- const lines = [`[req:next] ${a.kind} ${displayId}`, ` ${a.detail}`]
704
- if (a.command && a.kind === 'RUN') lines.push('', ` $ ${a.command}`)
705
- if (a.kind === 'AWAIT_HUMAN') {
706
- lines.push('', ` 통제점: ${a.controlPoint ?? '(미지정)'}`)
707
- lines.push(` 승인 문장: "${a.approvalSentence ?? '(미지정)'}"`)
708
- if (a.command) lines.push(` 승인 후 실행: $ ${a.command}`)
709
- }
710
- for (const d of a.diagnostics ?? []) lines.push(` - ${d}`)
711
- return lines.join('\n')
712
- }
713
-
714
- export function main(argv: string[] = process.argv.slice(2)): void {
715
- const opts = parseArgs(argv)
716
- const cfg = loadConfig({ root: opts.root })
717
- const roGit = createReadOnlyGit(createGitAdapter(cfg.root))
718
-
719
- // target은 **사용자가 티켓을 지목한 방식 그대로** 보존한다(R5). `--ticket`으로 읽었으면 후속 명령도 `--ticket`을 쓴다 —
720
- // 그러지 않으면 `req:review-codex -- <reqId>`가 기본 위치의 **다른 티켓**을 리뷰한다.
721
- if (!opts.ticket && !opts.reqId) throw new Error(missingTargetHint(cfg.packageManager))
722
- const target: NextTarget = opts.ticket
723
- ? { kind: 'ticket', ticketDir: opts.ticket }
724
- : { kind: 'req', reqId: (opts.reqId as string).replace(/^REQ-/, '') }
725
-
726
- const ticketDir =
727
- target.kind === 'ticket' ? resolve(target.ticketDir) : join(cfg.workflowDirAbs, `REQ-${target.reqId}`)
728
-
729
- const state = loadState(ticketDir)
730
- const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
731
-
732
- // 설계문서 인덱스 존재 + 현재 해시. 인덱스에 없으면 captureDesignBinding이 throw null로 흡수(2번 분기가 처리).
733
- let currentDesignHash: string | null = null
734
- try {
735
- currentDesignHash = captureDesignBinding(ticketRel, roGit, cfg.designDocs).designHash
736
- } catch {
737
- currentDesignHash = null
738
- }
739
-
740
- const statusEntries = parseStatusZ(roGit([...STATUS_Z_ARGS]))
741
- // review-codex/doctor와 동일한 스크래치 집합(lib/scratch SSOT) 워크플로 도구가 쓰는 메타데이터는 D10 대상이 아니다.
742
- const scratch = reviewScratchPaths(ticketRel)
743
-
744
- const action = resolveNext({
745
- target,
746
- state,
747
- packageManager: cfg.packageManager,
748
- designDocsInIndex: currentDesignHash !== null,
749
- currentDesignHash,
750
- hasStagedChanges: roGit(['diff', '--cached', '--name-only']).trim().length > 0,
751
- worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
752
- currentIndexHash: captureIndexHash(roGit),
753
- reviewBudget: cfg.reviewBudget,
754
- phaseCommitAutoApprove: cfg.phaseCommit.autoApprove,
755
- })
756
-
757
- if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))
758
- else console.log(renderAction(state.id, action))
759
-
760
- const code = nextExitCode(action.kind)
761
- if (code !== 0) process.exit(code)
762
- }
763
-
764
- /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
765
- export function runCli(argv: string[]): void {
766
- try {
767
- main(argv)
768
- } catch (err) {
769
- console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
770
- process.exitCode = 1
771
- }
772
- }
773
-
774
- const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
775
- if (isMain) main()
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:next — 워크플로의 **다음 행동**을 상태에서 계산해 한 줄로 알려준다 (REQ-2026-010 phase-2).
4
+ *
5
+ * 존재 이유: "끊지 말고 끝까지 진행하라"를 에이전트의 기억에 맡기면 컨텍스트가 길어질수록 신뢰도가 떨어진다.
6
+ * 다음 행동은 `state.json` + git 상태의 **결정론적 함수**이므로 도구가 계산한다. 에이전트 루프는 이렇게 짧아진다:
7
+ * `req:next`를 실행 → 시키는 것을 하고 → 다시 `req:next`. `AWAIT_HUMAN`이면 그 문장 그대로 승인받기 전엔 멈춘다.
8
+ *
9
+ * ⚠️ **읽기 전용이다**(D6-1). 어떤 상태도 쓰지 않는다.
10
+ * - `git write-tree` 금지 — object DB에 tree object를 쓴다. 그래서 바인딩 비교는 `captureIndexHash`(ls-files)로 한다.
11
+ * - `git status`·`git diff --cached`는 stat cache 갱신으로 `.git/index`를 **다시 쓴다**. 모든 호출을
12
+ * `--no-optional-locks`(= `GIT_OPTIONAL_LOCKS=0`의 CLI 등가물)로 감싼다.
13
+ * - `createReadOnlyGit`이 allowlist를 **런타임에도** 강제한다(테스트뿐 아니라 실행 중에도).
14
+ *
15
+ * ⚠️ **강제(enforcement)가 아니라 자문(advisory)이다.** 승인 게이트는 여전히 `req:review-codex`/`req:commit`에 있다.
16
+ * `req:next`가 틀려도 게이트는 뚫리지 않는다. 그래서 애매하면 `RUN` 쪽으로 기운다(fail-forward).
17
+ *
18
+ * 사용: req:next <REQ-id> [--json] [--root <path>] [--ticket <dir>] (저장소 패키지매니저의 실행 형식으로)
19
+ */
20
+ import { resolve, join, relative } from 'node:path'
21
+ import { pathToFileURL } from 'node:url'
22
+ import { loadConfig, buildScriptInvocation, type PackageManager } from './lib/config'
23
+ import { createGitAdapter, type GitAdapter } from './lib/adapters'
24
+ import { isDurabilityRequired, verifyCommittedDesignEvidence } from './lib/evidence'
25
+ import { createEvidencePorts } from './lib/evidence-ports'
26
+ import { parseStatusZ, STATUS_Z_ARGS } from './lib/porcelain'
27
+ import { reviewScratchPaths } from './lib/scratch'
28
+ import {
29
+ loadState,
30
+ readPhases,
31
+ captureDesignBinding,
32
+ captureIndexHash,
33
+ findUnstagedOrUntracked,
34
+ isLegacyTicket,
35
+ openSeriesAttempts,
36
+ isSeriesKeyTerminal,
37
+ type WorkflowState,
38
+ type ReviewKind,
39
+ type LastReviewMarker,
40
+ } from './review-codex'
41
+ import type { ReviewBudget, PhaseCommitPolicy } from './lib/config'
42
+
43
+ // ─────────────────────────────────────────────── 읽기 전용 git 경계 (D6-1) ──
44
+
45
+ type GitFn = (args: string[]) => string
46
+
47
+ /** `req:next`가 호출해도 되는 git subcommand. 전부 무쓰기. */
48
+ export const READONLY_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set(['rev-parse', 'status', 'diff', 'ls-files'])
49
+
50
+ /**
51
+ * argv에서 전역 플래그(`--no-optional-locks`, `-c <k=v>`, 기타 `-`로 시작)를 걷어낸 **첫 subcommand**.
52
+ * 없으면 null.
53
+ */
54
+ export function gitSubcommand(args: string[]): string | null {
55
+ for (let i = 0; i < args.length; i++) {
56
+ const a = args[i]
57
+ if (a === undefined) continue
58
+ if (a === '-c') {
59
+ i++ // -c 는 값을 하나 먹는다
60
+ continue
61
+ }
62
+ if (a.startsWith('-')) continue
63
+ return a
64
+ }
65
+ return null
66
+ }
67
+
68
+ /**
69
+ * 읽기 전용 git 래퍼. 가지를 한다.
70
+ * 1. 모든 호출 앞에 `--no-optional-locks`를 붙여 `.git/index` stat-cache 재기록을 막는다.
71
+ * 2. allowlist subcommand(`write-tree`·`add`·`commit`·`reset` …) **실행 전에 throw**한다.
72
+ *
73
+ * (2)가 방어의 핵심이다 — 나중에 누가 무심코 `captureGitBinding`(write-tree) 끌어 쓰면 즉시 터진다.
74
+ */
75
+ export function createReadOnlyGit(adapter: GitAdapter): GitFn {
76
+ return (args) => {
77
+ const sub = gitSubcommand(args)
78
+ if (sub === null || !READONLY_GIT_SUBCOMMANDS.has(sub))
79
+ throw new Error(
80
+ `req:next는 읽기 전용이다 — 허용되지 않은 git subcommand: ${sub ?? '(없음)'} (허용: ${[...READONLY_GIT_SUBCOMMANDS].join(', ')})`,
81
+ )
82
+ return adapter.exec(['--no-optional-locks', ...args])
83
+ }
84
+ }
85
+
86
+ // ──────────────────────────────────────────────────────── 판정 결과 타입 ──
87
+
88
+ export type NextKind = 'RUN' | 'AGENT' | 'AWAIT_HUMAN' | 'DONE' | 'BLOCKED'
89
+
90
+ export interface NextAction {
91
+ kind: NextKind
92
+ /** 사람이 읽는 설명. 문장. */
93
+ detail: string
94
+ /** kind=RUN일 때 그대로 실행할 명령. */
95
+ command?: string
96
+ /** kind=AWAIT_HUMAN일 때 통제점 식별자. */
97
+ controlPoint?: string
98
+ /** kind=AWAIT_HUMAN일 때 **그 문장 그대로** 받아야 하는 승인 문장. */
99
+ approvalSentence?: string
100
+ /** kind=BLOCKED일 때 진단(state 덤프·검증 오류). */
101
+ diagnostics?: string[]
102
+ }
103
+
104
+ /**
105
+ * exit 계약. `RUN`/`AGENT`는 0(계속 진행 가능), `AWAIT_HUMAN`/`DONE`은 "루프를 멈춰라"라서 0과 구분한다.
106
+ * `BLOCKED`=2는 `req:review-codex`의 blocked와 숫자를 맞춘다.
107
+ *
108
+ * ⚠️ `req:next`는 **CI 게이트가 아니다**. 판정 정본은 stdout(`--json`)의 `kind` 필드이고, exit code는 셸 루프 편의다.
109
+ * CI가 10/11을 실패로 읽지 않도록 주의.
110
+ */
111
+ export const NEXT_EXIT_CODES: Record<NextKind, number> = {
112
+ RUN: 0,
113
+ AGENT: 0,
114
+ BLOCKED: 2,
115
+ AWAIT_HUMAN: 10,
116
+ DONE: 11,
117
+ }
118
+
119
+ export function nextExitCode(kind: NextKind): number {
120
+ return NEXT_EXIT_CODES[kind]
121
+ }
122
+
123
+ // ────────────────────────────────────────────────────── 순수 판정 코어 ──
124
+
125
+ export interface NextInput {
126
+ /** 후속 명령이 대상으로 삼을 티켓. `--ticket`으로 읽었으면 그대로 보존된다(R5). */
127
+ target: NextTarget
128
+ state: WorkflowState
129
+ packageManager: PackageManager
130
+ /** 설계 문서 3종이 git 인덱스에 전부 있는가. */
131
+ designDocsInIndex: boolean
132
+ /** 현재 설계문서 바인딩 해시. 계산 불가면 null. */
133
+ currentDesignHash: string | null
134
+ hasStagedChanges: boolean
135
+ /** G1: `findUnstagedOrUntracked`가 비었는가(리뷰 가능한 워킹트리). */
136
+ worktreeReviewClean: boolean
137
+ /** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. */
138
+ currentIndexHash: string | null
139
+ /** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
140
+ reviewBudget: ReviewBudget
141
+ /**
142
+ * REQ-2026-037: phase 자동 커밋 정책. main이 `cfg.phaseCommit.autoApprove`로 채운다(항상 존재 — DEFAULTS=never).
143
+ * 필수 필드다(선택 아님): 해소는 config 계층에서 끝나므로 resolveNext는 내부 기본값을 두지 않는다.
144
+ */
145
+ phaseCommitAutoApprove: PhaseCommitPolicy
146
+ /**
147
+ * REQ-2026-048 DEC-4: **커밋된** design 증거 검증 결과. `main()`이 `HEAD` blob으로 계산해 채운다.
148
+ *
149
+ * - `undefined` = 미계산(2-arg/legacy 호출) 기존 DONE 동작 그대로.
150
+ * - `{ required:false }` = legacy 티켓(HEAD 스캐폴드에 marker 없음) → 기존 DONE 동작 그대로.
151
+ * - `{ required:true, durable:false }` = 신규 티켓인데 증거가 커밋되지 않음 → **DONE 대신 BLOCKED**.
152
+ */
153
+ designEvidenceDurability?: { required: boolean; durable: boolean; reason: string }
154
+ }
155
+
156
+ /** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
157
+ function readConsumed(state: WorkflowState): { phase_id: string | null }[] {
158
+ const raw = (state as { consumed_approvals?: unknown }).consumed_approvals
159
+ if (!Array.isArray(raw)) return []
160
+ return raw
161
+ .filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
162
+ .map((e) => ({ phase_id: typeof e.phase_id === 'string' ? e.phase_id : null }))
163
+ }
164
+
165
+ /**
166
+ * 다음 대상 phase (REQ-2026-010 design R2).
167
+ *
168
+ * ⚠️ 진행도의 정본은 `consumed_approvals[].phase_id`이지 `phases[].approved`가 **아니다.**
169
+ * `applyVerdict`는 승인 시 `approved`를 `true`로만 토글하고 미승인 시 되돌리지 않는다(sticky).
170
+ * 그래서 "승인 → 코드 수정 → 재리뷰 NEEDS_FIX" 상태에서 `approved`로 세면 대상이 0개가 되어 판정이 무너진다.
171
+ * `consumed_approvals`는 `req:commit`이 실제 커밋 시에만 append하는 append-only 원장이다.
172
+ *
173
+ * ⚠️ **전제: `phaseModelProblems(state)`가 비어 있어야 한다.** id가 중복이면 소비 1건이 같은 id의
174
+ * 모든 항목을 소비 처리해 `null`(=전부 끝남)을 반환한다. `resolveNext`가 호출 전에 걸러 준다.
175
+ */
176
+ export function nextPhaseId(state: WorkflowState): string | null {
177
+ const consumed = new Set(readConsumed(state).map((c) => c.phase_id).filter((p): p is string => p !== null))
178
+ const pending = readPhases(state).find((p) => !consumed.has(p.id))
179
+ return pending?.id ?? null
180
+ }
181
+
182
+ /**
183
+ * `req:next`가 **렌더링하는 명령의 argv 토큰**에 허용되는 형식 (phase-2 R3/R4 P2).
184
+ * `config.ts`의 designDocs basename 패턴과 같은 계약이며, 두 곳에 쓴다: `--phase <id>` 값, positional REQ id.
185
+ *
186
+ * - **선행 `-` 금지**: `review-codex`의 `parseArgs`는 `--phase` 값이 `-`로 시작하면 "값 누락"으로 throw하고,
187
+ * positional로 오면 unknown option으로 죽는다.
188
+ * - **공백·따옴표·세미콜론 금지**: `renderAction`이 argv를 `.join(' ')`로 렌더링하므로 argv 경계가 깨진다.
189
+ * `state.id = 'REQ-2026-010 bad'`면 `... -- 2026-010 bad --kind phase ...`가 되어 `bad`가 REQ id로 읽힌다.
190
+ *
191
+ * `req:next`의 계약은 "다음 행동을 알려준다"가 아니라 **"실행 가능하고 옳은 다음 행동만 알려준다"**이다.
192
+ * 렌더링할 수 없으면 `RUN`/`AWAIT_HUMAN`을 내지 않고 `BLOCKED`로 진단한다.
193
+ */
194
+ export const CLI_SAFE_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
195
+
196
+ /** phase id는 argv 토큰이다. `CLI_SAFE_ARG_RE`와 같은 계약. */
197
+ export const PHASE_ID_RE = CLI_SAFE_ARG_RE
198
+
199
+ /** REQ id(`REQ-` 접두 제거 후)도 positional argv 토큰이다. */
200
+ export const REQ_ID_RE = CLI_SAFE_ARG_RE
201
+
202
+ /**
203
+ * `--ticket <dir>` 값에서 **argv/셸 렌더링을 깨뜨리는** 문자 (phase-2 R6 P2).
204
+ *
205
+ * ⚠️ 화이트리스트를 쓰면 안 된다. 정상 경로가 전부 막힌다:
206
+ * `D:\proj\workflow\REQ-2026-010`(콜론) · `/tmp/x/REQ-2026-010`(선행 `/`) · `./workflow/REQ-2026-010`(선행 `.`).
207
+ * 그래서 **실제로 문제가 되는 문자만** 막는다: 공백류(`.join(' ')` 경계 파괴), 따옴표/백틱,
208
+ * 명령 구분·치환·리다이렉트 메타문자.
209
+ *
210
+ * 선행 `-`는 별도로 막는다(옵션으로 파싱된다).
211
+ * 참고: Windows 역슬래시 경로는 POSIX 셸에 그대로 붙여넣으면 이스케이프로 해석될 수 있다. `req:next`의
212
+ * 출력은 **표시용**이고 실행 주체는 사람/에이전트이므로, 여기서는 argv 경계만 보장한다.
213
+ */
214
+ export const UNSAFE_CLI_PATH_CHARS = /[\s"'`$;&|<>()*?!#~^%{}[\]]/
215
+
216
+ /** `--ticket <dir>` 값이 후속 명령에 그대로 실릴 수 있는지. */
217
+ export function ticketPathProblems(ticketDir: string): string[] {
218
+ if (ticketDir.trim() === '') return ['--ticket 경로가 비어 있다']
219
+ if (ticketDir.startsWith('-'))
220
+ return [`--ticket 경로가 '-'로 시작한다: ${JSON.stringify(ticketDir)} 후속 명령에서 옵션으로 파싱된다.`]
221
+ const bad = UNSAFE_CLI_PATH_CHARS.exec(ticketDir)
222
+ if (bad)
223
+ return [
224
+ `--ticket 경로에 argv/셸을 깨뜨리는 문자가 있다: ${JSON.stringify(bad[0])} in ${JSON.stringify(ticketDir)} 공백·따옴표·명령 구분자는 수 없다.`,
225
+ ]
226
+ return []
227
+ }
228
+
229
+ /**
230
+ * 후속 명령이 대상으로 삼을 티켓 (phase-2 R5 P2).
231
+ *
232
+ * ⚠️ **`reqId` 문자열만으로는 부족하다.** `req:next --ticket <dir>`로 비표준 위치의 티켓을 읽고
233
+ * `req:review-codex -- <reqId>`를 지시하면, 그 명령은 **기본 위치**(`workflow/REQ-<id>`)를 리뷰한다.
234
+ * 방금 판정한 티켓이 아니다. 그래서 "어떻게 지목했는가"를 그대로 보존해 명령에 되돌려 준다.
235
+ */
236
+ export type NextTarget = { kind: 'req'; reqId: string } | { kind: 'ticket'; ticketDir: string }
237
+
238
+ /** 후속 명령에 붙일 target argv. */
239
+ function targetArgs(t: NextTarget): string[] {
240
+ return t.kind === 'req' ? [t.reqId] : ['--ticket', t.ticketDir]
241
+ }
242
+
243
+ /**
244
+ * target이 후속 명령에 안전하고 **실제로 방금 판정한 티켓을 가리키는지** 검증한다 (phase-2 R5 P2).
245
+ *
246
+ * `kind: 'req'`에서 **identity 검증**이 핵심이다. `main()`이 `workflow/REQ-2026-010/state.json`을 읽었는데
247
+ * 안의 `id`가 `REQ-2026-999`면, argv-safe하다는 이유로 통과시켜선 된다 렌더링한 명령이
248
+ * **다른 티켓**을 대상으로 한다. 이 경우는 state 손상이므로 `BLOCKED`.
249
+ */
250
+ export function targetProblems(target: NextTarget, state: WorkflowState): string[] {
251
+ if (target.kind === 'ticket') return ticketPathProblems(target.ticketDir)
252
+ const problems = reqIdProblems(target.reqId)
253
+ if (problems.length) return problems
254
+ const expected = `REQ-${target.reqId}`
255
+ if (state.id !== expected)
256
+ return [
257
+ `state.json의 id(${JSON.stringify(state.id)})가 요청한 티켓(${expected})과 다르다 후속 명령이 다른 티켓을 대상으로 하게 된다. state.json을 확인하라.`,
258
+ ]
259
+ return []
260
+ }
261
+
262
+ /**
263
+ * `phases`를 진행도 계산에 쓸 수 있는지 검사한다 (phase-2 R1/R2/R3 P2). 문제가 있으면 사유 목록, 없으면 빈 배열.
264
+ *
265
+ * **조용한 오판정**으로 이어지는 같은 실패 class다.
266
+ *
267
+ * 1. **배열이 아님**(`phases: {…}` / `null`): `Array.isArray` 실패 `rawLen=0` → **레거시로 오분류**되어
268
+ * 소비 이력만 있으면 `DONE`이 나온다.
269
+ * 2. **malformed 항목**: `readPhases`가 `{id: string}`이 아닌 항목을 걸러내므로 배열이 비어 보이고
270
+ * `pending=null`이 된다(그런데 `rawLen>0`이라 레거시 분기로도 안 간다) 조용한 `DONE`.
271
+ * 3. **빈 id**(`id: ''`): `readPhases`는 통과시키지만 `--phase` 인자로 쓸 수 없다. `reviewCmd`가 `--phase`를
272
+ * 빠뜨린 명령을 지시하고, `review-codex`의 `resolvePhaseTarget`이 "대상 모호"로 죽는다.
273
+ * 4. **CLI-불안전 id**(`--bad`, 공백 포함): `req:next`가 **실행 불가능한 `RUN`**을 지시한다.
274
+ * `--phase --bad`는 `parseArgs`가 누락으로 throw하고, 공백은 `.join(' ')` 렌더링에서 argv를 깬다.
275
+ * 5. **중복 id**: `consumed_approvals`에 `p1` 1건만 있어도 `phases=[p1, p1]` 항목이 모두 소비 처리된다.
276
+ *
277
+ * 판정 불가면 조용히 넘어가지 않고 `BLOCKED`(fail-closed). state는 사람이 고쳐야 한다.
278
+ * ⚠️ `phases` **부재**와 **빈 배열**만이 정상적인 "여기서 판단하지 않음"이다(레거시 또는 미분해).
279
+ */
280
+ export function phaseModelProblems(state: WorkflowState): string[] {
281
+ const raw = (state as { phases?: unknown }).phases
282
+ if (raw === undefined) return [] // 부재 = 레거시. 다른 분기가 처리한다.
283
+ if (!Array.isArray(raw))
284
+ return [`phases가 배열이 아니다(${raw === null ? 'null' : typeof raw}) — 레거시로 오분류되어 조용히 DONE이 될 수 있다`]
285
+ if (raw.length === 0) return [] // 배열 = 레거시 또는 미분해.
286
+
287
+ const problems: string[] = []
288
+ const parsed = readPhases(state)
289
+ if (parsed.length !== raw.length)
290
+ problems.push(`phases[]에 형식이 잘못된 항목 ${raw.length - parsed.length}개(문자열 id 필요) — 진행도를 셀 수 없다`)
291
+
292
+ const empty = parsed.filter((p) => p.id.trim() === '').length
293
+ if (empty) problems.push(`phases[].id가 비어 있는 항목 ${empty}개 — \`--phase\` 인자로 쓸 수 없다`)
294
+
295
+ const unsafe = parsed.filter((p) => p.id.trim() !== '' && !PHASE_ID_RE.test(p.id)).map((p) => JSON.stringify(p.id))
296
+ if (unsafe.length)
297
+ problems.push(
298
+ `phases[].id가 CLI 인자로 안전하지 않다: ${unsafe.join(', ')} — ${String(PHASE_ID_RE)} 형식이어야 한다(선행 '-'는 --phase 값 누락으로, 공백은 argv 깨짐으로 이어진다)`,
299
+ )
300
+
301
+ const seen = new Set<string>()
302
+ const dup = new Set<string>()
303
+ for (const p of parsed) {
304
+ if (seen.has(p.id)) dup.add(p.id)
305
+ seen.add(p.id)
306
+ }
307
+ if (dup.size) problems.push(`phases[].id 중복: ${[...dup].join(', ')} 소비 1건이 같은 id의 모든 항목을 소비 처리한다`)
308
+
309
+ return problems
310
+ }
311
+
312
+ /**
313
+ * 렌더링할 명령의 positional REQ id가 argv-안전한지 (phase-2 R4 P2).
314
+ *
315
+ * `main()`은 CLI 인자가 아니라 **`state.id`에서 파생한** reqId를 쓴다(`state.id.replace(/^REQ-/, '')`).
316
+ * 그래서 `state.json`이 손상되면 `req:next`가 **다른 티켓을 대상으로 하는 명령**을 지시할 수 있다:
317
+ * `state.id = 'REQ-2026-010 bad'` → `... -- 2026-010 bad --kind phase ...` → `bad`가 REQ id로 읽힌다.
318
+ * `state.id = 'REQ---bad'` → `... -- --bad ...` → unknown option으로 죽는다.
319
+ */
320
+ export function reqIdProblems(reqId: string): string[] {
321
+ if (REQ_ID_RE.test(reqId)) return []
322
+ return [
323
+ `REQ id가 CLI 인자로 안전하지 않다: ${JSON.stringify(reqId)} ${String(REQ_ID_RE)} 형식이어야 한다. state.json의 \`id\`를 확인하라.`,
324
+ ]
325
+ }
326
+
327
+ /**
328
+ * `phaseId === null`은 **레거시**(phase 미추적)라 `--phase`를 붙이지 않는 것이 옳다.
329
+ * 문자열 id는 여기 도달할 수 없다 — `phaseModelProblems`의 0번 분기가 먼저 `BLOCKED`로 막는다.
330
+ * (도달했다면 `--phase` 없는 명령이 나가고 `resolvePhaseTarget`이 "대상 모호"로 죽는다.)
331
+ */
332
+ function reviewCmd(pm: PackageManager, target: NextTarget, kind: ReviewKind, phaseId: string | null): string {
333
+ const args = [...targetArgs(target), '--kind', kind]
334
+ if (phaseId !== null) args.push('--phase', phaseId)
335
+ args.push('--run')
336
+ return buildScriptInvocation(pm, 'req:review-codex', args).join(' ')
337
+ }
338
+
339
+ function commitCmd(pm: PackageManager, target: NextTarget): string {
340
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run']).join(' ')
341
+ }
342
+
343
+ /**
344
+ * REQ-2026-037: 자동 커밋 RUN 명령. `commitCmd`와 달리 `-m "<메시지>"` 자리표시자를 싣는다 — `req:commit`은
345
+ * 메시지 없이는 fail-closed로 죽기 때문(read-only인 req:next는 메시지를 합성할 수 없다). 에이전트가 이
346
+ * 자리표시자를 실제 conventional 메시지로 바꿔 실행한다(AGENT 단계에서 `git add` 대상을 고르는 것과 동형).
347
+ */
348
+ function autoCommitCmd(pm: PackageManager, target: NextTarget): string {
349
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run', '-m', '"<이 phase의 conventional 커밋 메시지>"']).join(' ')
350
+ }
351
+
352
+ /**
353
+ * REQ-2026-037: 부분 커밋(source 커밋 후 consume 전) 복구 명령. `req:commit --finalize --run`은 source를
354
+ * 재커밋하지 않고 evidence/consume만 복구한다 — 복구 가드가 안내하는 정확한 명령(detail·command·approvalSentence 일관).
355
+ */
356
+ function finalizeCmd(pm: PackageManager, target: NextTarget): string {
357
+ return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--finalize', '--run']).join(' ')
358
+ }
359
+
360
+ /**
361
+ * 대상(REQ id 또는 `--ticket`) 미지정 에러 문구(DEC-011-1). **config 로드 이후**라 pm별로 파생한다.
362
+ * 리터럴을 박으면 다른 pm 프로젝트의 사용자가 그대로 따라 없는 명령을 안내받는다.
363
+ */
364
+ export function missingTargetHint(pm: PackageManager): string {
365
+ return `REQ id 또는 --ticket <dir> 필요 (예: ${buildScriptInvocation(pm, 'req:next', ['2026-010']).join(' ')})`
366
+ }
367
+
368
+ interface RunCandidate {
369
+ command: string
370
+ kind: ReviewKind
371
+ phaseId: string | null
372
+ /** 이 리뷰가 바인딩할 대상의 현재 해시. null이면 비교 불가 → G2 통과(fail-forward). */
373
+ compareHash: string | null
374
+ detail: string
375
+ }
376
+
377
+ /**
378
+ * `RUN` 후보에 두 게이트를 적용한다. 통과 못 하면 다른 kind로 강등한다.
379
+ *
380
+ * **G1 (D10 전제)**: `review-codex`의 `main()`은 호출 전 워킹트리가 staged+스크래치뿐인지 검사해
381
+ * 아니면 throw한다. 그걸 모른 채 `RUN`을 지시하면 그 명령은 즉시 죽는다.
382
+ *
383
+ * **G2 (바인딩 신선도, outcome-aware)**: `last_review`가 같은 `(kind, phase_id)` + 같은 `compare_hash`면
384
+ * 직전 리뷰가 이미 이 바인딩을 봤다는 뜻이다. NEEDS_FIX 후에도 staged는 남으므로, 이걸 안 보면
385
+ * 같은 바인딩을 무한 재리뷰한다(`blocked_review` 회로차단기는 BLOCKED만 잡고 NEEDS_FIX는 못 잡는다).
386
+ */
387
+ function gateRunCandidate(input: NextInput, cand: RunCandidate): NextAction {
388
+ // G1
389
+ if (!input.worktreeReviewClean)
390
+ return {
391
+ kind: 'AGENT',
392
+ detail:
393
+ '워킹트리에 unstaged/untracked 변경이 있어 리뷰(D10)가 실패한다. 의도한 변경은 `git add`, 그 외는 정리한 뒤 다시 req:next.',
394
+ }
395
+
396
+ // terminal (REQ-2026-029 A-2b): human-resolution으로 종결된 키 → AWAIT_HUMAN. **G3보다 앞**(R4) — 종결된
397
+ // series는 예산 안내("고치고 예외 받으라")가 아니라 "이미 끝났다 — 대체 REQ" 안내가 맞다. G1보다는 뒤
398
+ // (dirty면 정리 먼저). 우선순위: G1terminal G3 G2.
399
+ if (isSeriesKeyTerminal(input.state, cand.kind, cand.phaseId))
400
+ return {
401
+ kind: 'AWAIT_HUMAN',
402
+ detail: '이 series는 human-resolution으로 종결됐다. 같은 키에서 자동으로 재개하지 않는다.',
403
+ controlPoint: 'human-resolution 종결됨',
404
+ approvalSentence: '대체가 필요하면 `req:new --successor-of <이 REQ>`로 만든다(종결 상태 유지 결정도 사람이 한다)',
405
+ diagnostics: ['종결 사유: human-resolution', '재개는 자동으로 일어나지 않는다 대체 REQ 또는 종결 유지.'],
406
+ }
407
+
408
+ // G3 (REQ-2026-028 A-2a): 자동 예산 소진(escalated) → AWAIT_HUMAN. **G2보다 앞**(R13) — 5회차 NEEDS_FIX
409
+ // 직후엔 escalated와 같은 바인딩 needs-fix가 동시 성립하는데, G2가 먼저 "findings 고치고 다시 add"(AGENT)
410
+ // 내면 그 조언이 거짓이다(고쳐도 사람 승인 없이 6회차가 열린다). escalated는 파생값(저장 함, R11).
411
+ const openAttempts = openSeriesAttempts(input.state, cand.kind, cand.phaseId)
412
+ const { autoBudget, hardCap } = input.reviewBudget
413
+ if (openAttempts >= autoBudget) {
414
+ const nextAttempt = openAttempts + 1
415
+ const lrOutcome = (input.state.last_review as LastReviewMarker | undefined)?.outcome ?? '(없음)'
416
+ const hardBlocked = openAttempts >= hardCap
417
+ // ⚠️ "위험 수용"은 어느 문구에도 넣지 않는다(배분표 ④ — 부정문으로도 금지). 긍정 선택지만 나열.
418
+ const options = hardBlocked
419
+ ? '예외로도 진행 불가 종료하거나 정합한 대체 REQ 작성한다.'
420
+ : '사람 승인 시 1회 예외 가능(review_exception_confirmed) · 종료 · 정합한 대체 REQ 작성.'
421
+ return {
422
+ kind: 'AWAIT_HUMAN',
423
+ detail: hardBlocked
424
+ ? `이 series는 하드 상한(hardCap=${hardCap})에 도달했다. ${nextAttempt}회차는 어떤 경로로도 실행하지 않는다.`
425
+ : `이 series는 자동 예산(autoBudget=${autoBudget})을 소진했다. ${nextAttempt}회차는 사람 결정이 필요하다.`,
426
+ controlPoint: 'review 예산 소진(escalated)',
427
+ approvalSentence: hardBlocked
428
+ ? 'review 하드 상한 도달 — 종료 또는 대체 REQ 작성(둘 중 하나를 사람이 결정)'
429
+ : `review ${nextAttempt}회차 예외 승인(또는 종료·대체 REQ 작성)`,
430
+ diagnostics: [
431
+ `series 시도 수(openAttempts)=${openAttempts} · 다음 회차=${nextAttempt}`,
432
+ `직전 리뷰 outcome=${lrOutcome}`,
433
+ `선택지: ${options}`,
434
+ ],
435
+ }
436
+ }
437
+
438
+ // G2
439
+ const lr = input.state.last_review as LastReviewMarker | undefined
440
+ const sameTarget =
441
+ !!lr &&
442
+ lr.review_kind === cand.kind &&
443
+ (lr.phase_id ?? null) === cand.phaseId &&
444
+ typeof lr.compare_hash === 'string' &&
445
+ cand.compareHash !== null &&
446
+ lr.compare_hash === cand.compareHash
447
+
448
+ if (sameTarget && lr) {
449
+ switch (lr.outcome) {
450
+ case 'needs-fix':
451
+ return {
452
+ kind: 'AGENT',
453
+ detail: `직전 리뷰가 이 바인딩을 보고 NEEDS_FIX를 냈다. findings를 수정하고 \`git add\` 후 다시 req:next. (같은 바인딩 재리뷰는 낭비)`,
454
+ }
455
+ case 'blocked':
456
+ return {
457
+ kind: 'BLOCKED',
458
+ detail:
459
+ '직전 리뷰가 바인딩에서 BLOCKED(지적 없이 미승인)였다. 같은 리뷰를 재시도하지 리뷰 대상을 바꾸거나 사람이 판단한다.',
460
+ diagnostics: [
461
+ 'AGENTS.md §3: BLOCKED(exit 2)는 같은 리뷰 재시도 금지.',
462
+ '스레드 고착이 의심되면 사람이 `--fresh-thread`로 1회만 회복을 시도할 수 있다(req:next는 자동으로 지시하지 않는다 회로차단기가 무력화된다).',
463
+ ],
464
+ }
465
+ case 'invalid':
466
+ if (lr.count >= 2)
467
+ return {
468
+ kind: 'BLOCKED',
469
+ detail: `같은 바인딩에서 리뷰 응답이 ${lr.count}회 연속 무효(구조/도메인 검증 실패)다. 도구·스키마 문제로 보고 사람에게 보고한다.`,
470
+ diagnostics: lr.errors.length ? lr.errors : ['(저장된 검증 오류 없음)'],
471
+ }
472
+ return { kind: 'RUN', detail: `${cand.detail} (직전 응답이 무효였다 — 1회 재시도)`, command: cand.command }
473
+ case 'approved':
474
+ return {
475
+ kind: 'BLOCKED',
476
+ detail:
477
+ '방어적 차단: 이 바인딩은 이미 승인됐는데 승인 상태가 state에 보이지 않는다. state가 손상됐을 수 있다.',
478
+ diagnostics: [`last_review=${JSON.stringify(lr)}`, `commit_allowed=${String(input.state.commit_allowed)}`],
479
+ }
480
+ default:
481
+ break // 없는 outcome fail-forward
482
+ }
483
+ }
484
+
485
+ return { kind: 'RUN', detail: cand.detail, command: cand.command }
486
+ }
487
+
488
+ /**
489
+ * 다음 행동 판정(순수). 먼저 매치되는 분기가 이긴다.
490
+ *
491
+ * ⚠️ `blocked_review`를 **읽지 않는다**(design R5 P2). 그 마커의 `review_binding`은 phase에서 tree OID라
492
+ * `req:next`가 재계산할 없어 "현재 바인딩에 대한 것인가" 판정할 없다. stale 마커로 영구히 막히는
493
+ * 것보다, G2(`last_review.compare_hash`) 바인딩 변경을 정확히 감지하는 편이 맞다. 회로차단기의 **강제**는
494
+ * `review-codex`의 `shouldShortCircuitBlockedReview`에 그대로 남아 있다(codex 호출 없이 exit 2).
495
+ */
496
+ export function resolveNext(input: NextInput): NextAction {
497
+ const { state, packageManager: pm, target } = input
498
+
499
+ // 0. state를 신뢰할 수 없으면 **아무 판정도 하지 않는다**(phase-2 R1/R2/R3/R4 P2).
500
+ // - reqId/phase id가 argv-불안전하면 렌더링한 명령이 실행 불가능하거나 **엉뚱한 티켓**을 대상으로 한다.
501
+ // - phases[]가 손상되면 nextPhaseId가 null을 반환해 조용한 DONE으로 이어진다.
502
+ // 살아 있는 승인(1번)보다도 먼저 막는다 — 손상된 state에서 "커밋을 승인하라"고 말하면 엉뚱한 phase가 소비된다.
503
+ const modelProblems = [...targetProblems(target, state), ...phaseModelProblems(state)]
504
+ if (modelProblems.length)
505
+ return {
506
+ kind: 'BLOCKED',
507
+ detail: 'state.json을 신뢰할 없어 다음 행동을 판정하지 않는다. 사람이 state 고쳐야 한다.',
508
+ diagnostics: modelProblems,
509
+ }
510
+
511
+ // 1. 살아 있는 승인이 가장 쉽게 상한다 다른 어떤 행동도 D9(staged tree == approved tree)를 깨뜨린다.
512
+ if (state.commit_allowed === true) {
513
+ // REQ-2026-037: opt-in 자동 커밋. **fail-closed** — `risk_level==='LOW'` 정확 일치 AND 정책 low-only AND
514
+ // staged 존재일 때만 RUN(자동 커밋). "HIGH아님"이 "자동 안전"을 의미하지 않는다: 누락·`'Low'` 오타·
515
+ // 손상·HIGH는 전부 else(AWAIT_HUMAN)로 떨어지고, HIGH는 req-commit Gate B가 이중 백스톱.
516
+ const autoCommit =
517
+ input.phaseCommitAutoApprove === 'low-only' && state.risk_level === 'LOW' && input.hasStagedChanges
518
+ if (autoCommit)
519
+ return {
520
+ kind: 'RUN',
521
+ detail: 'phase 승인이 살아 있다(LOW · 자동 커밋). 이 phase의 conventional 커밋 메시지를 작성해 실행하라.',
522
+ command: autoCommitCmd(pm, target),
523
+ }
524
+ // 복구 가드(R4): 승인이 살아 있는데 staged가 비었으면 부분 커밋(source 커밋 후 consume 전)일 수 있다.
525
+ // 정상 커밋을 지시하면 req:commit이 `staged 변경 없음`으로 죽어 자동 루프가 스핀한다 → --finalize로 복구.
526
+ // detail·command·controlPoint·approvalSentence를 모두 finalize로 맞춘다(phase-2 리뷰 observation).
527
+ if (!input.hasStagedChanges)
528
+ return {
529
+ kind: 'AWAIT_HUMAN',
530
+ detail: 'phase 승인이 살아 있으나 staged가 비었다 — 부분 커밋일 수 있다. `req:commit --finalize --run`으로 복구가 필요하다.',
531
+ command: finalizeCmd(pm, target),
532
+ controlPoint: 'req:commit --finalize --run 직전',
533
+ approvalSentence: 'req:commit --finalize --run 승인',
534
+ }
535
+ return {
536
+ kind: 'AWAIT_HUMAN',
537
+ detail: 'phase 승인이 살아 있다. 커밋 전 사람 확인이 필요하다.',
538
+ command: commitCmd(pm, target),
539
+ controlPoint: 'req:commit --run 직전',
540
+ approvalSentence: 'req:commit --run 승인',
541
+ }
542
+ }
543
+
544
+ // 1.5 legacy ticket(REQ-2026-027 D1): 모델 버전 부재 = legacy. 살아 있는 승인(1번)보다는 뒤 —
545
+ // 그건 소비만 하면 되고 새 외부 호출이 아니다. design/phase RUN 후보(2·3번)보다는 **앞** — 그 후보를
546
+ // 내면 사용자가 실행한 뒤에야 호출 지점에서 throw된다(R2는 AWAIT_HUMAN을 요구). 자동 초기화하지 않는다.
547
+ if (isLegacyTicket(state))
548
+ return {
549
+ kind: 'AWAIT_HUMAN',
550
+ detail:
551
+ 'legacy ticket(review_series_model_version 부재)이다. 자동으로 새 모델로 초기화하지 않는다 — 사람이 이 티켓을 새 series 모델로 채택할지 결정해야 한다.',
552
+ controlPoint: 'legacy 티켓 채택',
553
+ approvalSentence: 'state.json에 review_series_model_version: 1 추가( 티켓을 모델로 채택) 승인',
554
+ }
555
+
556
+ // 2. 설계 문서가 인덱스에 없으면 3번의 freshness 판정(captureDesignBinding)이 throw한다. 여기서 먼저 거른다.
557
+ if (!input.designDocsInIndex)
558
+ return {
559
+ kind: 'AGENT',
560
+ detail: '설계 문서 00/01/02가 git 인덱스에 없다. 작성한 뒤 `git add` 하고 다시 req:next.',
561
+ }
562
+
563
+ // 3. design 미승인 또는 stale(문서가 승인 이후 바뀜).
564
+ const designApprovedHash = typeof state.design_approved_hash === 'string' ? state.design_approved_hash : null
565
+ const designValid =
566
+ state.design_approved === true && designApprovedHash !== null && designApprovedHash === input.currentDesignHash
567
+ if (!designValid)
568
+ return gateRunCandidate(input, {
569
+ command: reviewCmd(pm, target, 'design', null),
570
+ kind: 'design',
571
+ phaseId: null,
572
+ compareHash: input.currentDesignHash,
573
+ detail: state.design_approved === true ? '설계 문서가 승인 이후 변경됐다(stale). 재승인이 필요하다.' : '설계 승인이 필요하다.',
574
+ })
575
+
576
+ const rawPhases = (state as { phases?: unknown }).phases
577
+ const rawLen = Array.isArray(rawPhases) ? rawPhases.length : 0
578
+ const consumed = readConsumed(state)
579
+
580
+ if (rawLen === 0) {
581
+ // 4. 신규 티켓(req:new이 approval_evidence_required=true를 심는다) — 아직 phase를 안 나눴다.
582
+ if (state.approval_evidence_required === true)
583
+ return {
584
+ kind: 'AGENT',
585
+ detail: '`02-plan.md`에 phase를 분해하고 `state.json`의 `phases[]`를 채운 뒤 다시 req:next.',
586
+ }
587
+
588
+ // 5~7. 레거시 티켓(필드 자체가 없음 — phase 추적 없이 리뷰하던 시절).
589
+ if (!('approval_evidence_required' in state))
590
+ return resolveLegacy(input, consumed)
591
+
592
+ return {
593
+ kind: 'BLOCKED',
594
+ detail: 'phases[]가 비었는데 approval_evidence_required가 true도 아니고 부재도 아니다 — 신규/레거시를 구분할 수 없다.',
595
+ diagnostics: [`approval_evidence_required=${JSON.stringify(state.approval_evidence_required)}`],
596
+ }
597
+ }
598
+
599
+ // 8~10. phase 추적 티켓.
600
+ const pending = nextPhaseId(state)
601
+ if (pending !== null) {
602
+ if (!input.hasStagedChanges)
603
+ return { kind: 'AGENT', detail: `phase \`${pending}\`를 구현하고 테스트를 통과시킨 뒤 \`git add\` 하고 다시 req:next.` }
604
+ return gateRunCandidate(input, {
605
+ command: reviewCmd(pm, target, 'phase', pending),
606
+ kind: 'phase',
607
+ phaseId: pending,
608
+ compareHash: input.currentIndexHash,
609
+ detail: `phase \`${pending}\`의 staged 변경을 리뷰받는다.`,
610
+ })
611
+ }
612
+
613
+ // ── REQ-2026-048 DEC-4: 완료 선언 직전 **커밋된** design 증거 검증(신규 티켓 전용) ──
614
+ // 🔴 여기서만 fail-closed다. `req:doctor`·일반 `req:commit`에는 넣지 않는다doctor는 req:commit의
615
+ // 하드 게이트라 FAIL이면 기존 소비자의 모든 커밋이 벽돌이 된다. 완료 판정만 막으면 충분하다.
616
+ // 🔴 온디스크가 아니라 HEAD blob 기준이다(D17이 온디스크로 통과해 이 갭이 조용했다).
617
+ {
618
+ const dur = input.designEvidenceDurability
619
+ if (input.worktreeReviewClean && !input.hasStagedChanges && dur?.required === true && dur.durable !== true) {
620
+ const cmd = buildScriptInvocation(input.packageManager, 'req:commit', [
621
+ ...targetArgs(input.target),
622
+ '--finalize-design',
623
+ '--run',
624
+ ]).join(' ')
625
+ return {
626
+ kind: 'BLOCKED',
627
+ detail:
628
+ `모든 phase가 끝났지만 **커밋된** design 승인 증거가 완비되지 않았다: ${dur.reason}. ` +
629
+ `이 상태로 통합하면 fresh clone에 설계 승인 감사 증거가 남지 않는다. 복구: \`${cmd}\``,
630
+ }
631
+ }
632
+ }
633
+
634
+ if (input.worktreeReviewClean && !input.hasStagedChanges) {
635
+ // REQ-2026-037 R5: 자동 커밋(low-only)에선 매 phase 정지가 없으므로, 병합 전 **단일** 사람 확인을
636
+ // 종단에서 실체화한다 — DONE(exit 11)이 아니라 AWAIT_HUMAN(exit 10)으로 루프를 확실히 멈춘다.
637
+ // 승인 문장은 새로 만들지 않고 계약 통제점표(I1/I2/B1)의 정본을 가리킨다(design-r01 observation).
638
+ if (input.phaseCommitAutoApprove === 'low-only')
639
+ return {
640
+ kind: 'AWAIT_HUMAN',
641
+ detail:
642
+ '모든 phase가 자동 커밋됐다. feature→main 통합은 사람 승인이 필요하다 — 경로(PR 또는 direct push) 승인 문장은 AGENTS.md 통제점표(I1/I2/B1)를 따른다.',
643
+ controlPoint: '통합(feature→main)',
644
+ approvalSentence:
645
+ '통합 경로를 택하고 그 통제점의 정본 승인 문장을 받는다 — [I1] feature branch push + PR 생성 승인 → [I2] required checks green 확인 후 PR merge 승인, 또는 [B1] branch protection bypass를 사용한 direct push 승인',
646
+ }
647
+ // never(기본): 현행 그대로 DONE — 기존 사용자 무회귀.
648
+ return {
649
+ kind: 'DONE',
650
+ detail:
651
+ '모든 phase 승인·커밋됐다. 다음은 통합 통제점 — `[I1]` PR 생성 또는 `[B1]` protected branch direct push. 경로 선택과 승인은 사람이 한다.',
652
+ }
653
+ }
654
+
655
+ return {
656
+ kind: 'BLOCKED',
657
+ detail: '모든 phase가 소비됐는데 워킹트리가 깨끗하지 않다. 남은 변경이 이 티켓 범위인지 사람이 판단해야 한다.',
658
+ diagnostics: [
659
+ `hasStagedChanges=${String(input.hasStagedChanges)}`,
660
+ `worktreeReviewClean=${String(input.worktreeReviewClean)}`,
661
+ `phases=${readPhases(state).map((p) => p.id).join(', ') || '(없음)'}`,
662
+ `consumed=${consumed.map((c) => c.phase_id ?? '(null)').join(', ') || '(없음)'}`,
663
+ ],
664
+ }
665
+ }
666
+
667
+ /**
668
+ * 레거시 티켓(`phases[]` 없음 + `approval_evidence_required` 필드 부재).
669
+ *
670
+ * `phases[]`가 없어 "전부 consumed"가 vacuous truth가 되므로, 소비 이력으로만 완료를 판정한다(design R2 P2-A).
671
+ * 남은 phase가 있는지는 **도구가 알 수 없다** — `DONE`의 detail이 그 사실을 말한다. 조용히 "다 끝났다"고 하지 않는다.
672
+ */
673
+ function resolveLegacy(input: NextInput, consumed: { phase_id: string | null }[]): NextAction {
674
+ const { state, packageManager: pm, target } = input
675
+
676
+ if (input.hasStagedChanges)
677
+ return gateRunCandidate(input, {
678
+ command: reviewCmd(pm, target, 'phase', null),
679
+ kind: 'phase',
680
+ phaseId: null,
681
+ compareHash: input.currentIndexHash,
682
+ detail: '레거시 티켓(phase 미추적)의 staged 변경을 리뷰받는다.',
683
+ })
684
+
685
+ if (consumed.length === 0)
686
+ return { kind: 'AGENT', detail: '구현하고 테스트를 통과시킨 `git add` 하고 다시 req:next.' }
687
+
688
+ if (input.worktreeReviewClean)
689
+ return {
690
+ kind: 'DONE',
691
+ detail:
692
+ '레거시 티켓(phases[] 미추적) — 승인·커밋 이력이 있고 워킹트리가 깨끗하다. **남은 phase 여부는 도구가 알 수 없다**: `02-plan.md`를 확인하라. 통합은 `[I1]`/`[B1]` 통제점.',
693
+ }
694
+
695
+ return {
696
+ kind: 'BLOCKED',
697
+ detail: '레거시 티켓에 소비 이력이 있지만 워킹트리가 깨끗하지 않다. 남은 변경을 사람이 판단해야 한다.',
698
+ diagnostics: [`consumed=${consumed.length}건`, `worktreeReviewClean=false`],
699
+ }
700
+ }
701
+
702
+ // ──────────────────────────────────────────────────────────────── CLI ──
703
+
704
+ export interface Opts {
705
+ reqId: string | null
706
+ ticket: string | null
707
+ root: string | null
708
+ json: boolean
709
+ }
710
+
711
+ export function parseArgs(argv: string[]): Opts {
712
+ const o: Opts = { reqId: null, ticket: null, root: null, json: false }
713
+ for (let i = 0; i < argv.length; i++) {
714
+ const a = argv[i]
715
+ if (a === undefined) continue
716
+ // bare `--`는 옵션이 아니라 POSIX end-of-options 마커다(DEC-011-3). npm은 `npm run x -- a`에서
717
+ // 이를 제거하지만 pnpm/yarn은 그대로 넘긴다 → 흡수한다. 이후 인자도 계속 옵션으로 파싱한다
718
+ // (전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run이 된다).
719
+ if (a === '--') continue
720
+ else if (a === '--json') o.json = true
721
+ else if (a === '--ticket') o.ticket = argv[++i] ?? null
722
+ else if (a === '--root') {
723
+ const v = argv[++i]
724
+ if (v === undefined) throw new Error('--root 필요')
725
+ o.root = v
726
+ } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
727
+ else o.reqId = a
728
+ }
729
+ return o
730
+ }
731
+
732
+ /** 사람이 읽는 출력. `displayId`는 표시 전용(argv가 아니다) `state.id`를 그대로 쓴다. */
733
+ export function renderAction(displayId: string, a: NextAction): string {
734
+ const lines = [`[req:next] ${a.kind} ${displayId}`, ` ${a.detail}`]
735
+ if (a.command && a.kind === 'RUN') lines.push('', ` $ ${a.command}`)
736
+ if (a.kind === 'AWAIT_HUMAN') {
737
+ lines.push('', ` 통제점: ${a.controlPoint ?? '(미지정)'}`)
738
+ lines.push(` 승인 문장: "${a.approvalSentence ?? '(미지정)'}"`)
739
+ if (a.command) lines.push(` 승인 후 실행: $ ${a.command}`)
740
+ }
741
+ for (const d of a.diagnostics ?? []) lines.push(` - ${d}`)
742
+ return lines.join('\n')
743
+ }
744
+
745
+ export function main(argv: string[] = process.argv.slice(2)): void {
746
+ const opts = parseArgs(argv)
747
+ const cfg = loadConfig({ root: opts.root })
748
+ const roGit = createReadOnlyGit(createGitAdapter(cfg.root))
749
+
750
+ // target은 **사용자가 티켓을 지목한 방식 그대로** 보존한다(R5). `--ticket`으로 읽었으면 후속 명령도 `--ticket`을 쓴다 —
751
+ // 그러지 않으면 `req:review-codex -- <reqId>`가 기본 위치의 **다른 티켓**을 리뷰한다.
752
+ if (!opts.ticket && !opts.reqId) throw new Error(missingTargetHint(cfg.packageManager))
753
+ const target: NextTarget = opts.ticket
754
+ ? { kind: 'ticket', ticketDir: opts.ticket }
755
+ : { kind: 'req', reqId: (opts.reqId as string).replace(/^REQ-/, '') }
756
+
757
+ const ticketDir =
758
+ target.kind === 'ticket' ? resolve(target.ticketDir) : join(cfg.workflowDirAbs, `REQ-${target.reqId}`)
759
+
760
+ const state = loadState(ticketDir)
761
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
762
+
763
+ // 설계문서 인덱스 존재 + 현재 해시. 인덱스에 없으면 captureDesignBinding이 throw → null로 흡수(2번 분기가 처리).
764
+ let currentDesignHash: string | null = null
765
+ try {
766
+ currentDesignHash = captureDesignBinding(ticketRel, roGit, cfg.designDocs).designHash
767
+ } catch {
768
+ currentDesignHash = null
769
+ }
770
+
771
+ const statusEntries = parseStatusZ(roGit([...STATUS_Z_ARGS]))
772
+ // review-codex/doctor와 동일한 스크래치 집합(lib/scratch SSOT) — 워크플로 도구가 쓰는 메타데이터는 D10 대상이 아니다.
773
+ const scratch = reviewScratchPaths(ticketRel)
774
+
775
+ const action = resolveNext({
776
+ target,
777
+ state,
778
+ packageManager: cfg.packageManager,
779
+ designDocsInIndex: currentDesignHash !== null,
780
+ currentDesignHash,
781
+ hasStagedChanges: roGit(['diff', '--cached', '--name-only']).trim().length > 0,
782
+ worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
783
+ currentIndexHash: captureIndexHash(roGit),
784
+ reviewBudget: cfg.reviewBudget,
785
+ phaseCommitAutoApprove: cfg.phaseCommit.autoApprove,
786
+ // REQ-2026-048 DEC-4: marker와 증거 모두 **HEAD blob**에서 읽는다(워킹 캐시 신뢰 금지).
787
+ designEvidenceDurability: (() => {
788
+ const ports = createEvidencePorts(cfg.root, `${ticketRel}/responses`)
789
+ const required = isDurabilityRequired(ports.headText(`${ticketRel}/state.json`))
790
+ if (!required) return { required: false, durable: true, reason: 'legacy 티켓(marker 없음) — 점검 불요' }
791
+ return { required: true, ...verifyCommittedDesignEvidence({ ticketRel, ports }) }
792
+ })(),
793
+ })
794
+
795
+ if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))
796
+ else console.log(renderAction(state.id, action))
797
+
798
+ const code = nextExitCode(action.kind)
799
+ if (code !== 0) process.exit(code)
800
+ }
801
+
802
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
803
+ export function runCli(argv: string[]): void {
804
+ try {
805
+ main(argv)
806
+ } catch (err) {
807
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
808
+ process.exitCode = 1
809
+ }
810
+ }
811
+
812
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
813
+ if (isMain) main()