commitgate 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,72 +1,73 @@
1
- {
2
- "name": "commitgate",
3
- "version": "0.7.0",
4
- "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "sol5288",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/sol5288/commitgate.git"
11
- },
12
- "homepage": "https://github.com/sol5288/commitgate#readme",
13
- "bugs": {
14
- "url": "https://github.com/sol5288/commitgate/issues"
15
- },
16
- "keywords": [
17
- "ai",
18
- "code-review",
19
- "workflow",
20
- "codex",
21
- "claude",
22
- "git",
23
- "commit-gate",
24
- "fail-closed",
25
- "review-gate",
26
- "cli"
27
- ],
28
- "bin": {
29
- "commitgate": "bin/commitgate.mjs"
30
- },
31
- "scripts": {
32
- "req:new": "tsx scripts/req/req-new.ts",
33
- "req:review-codex": "tsx scripts/req/review-codex.ts",
34
- "req:doctor": "tsx scripts/req/req-doctor.ts",
35
- "req:next": "tsx scripts/req/req-next.ts",
36
- "req:commit": "tsx scripts/req/req-commit.ts",
37
- "test": "vitest run",
38
- "typecheck": "tsc --noEmit",
39
- "smoke": "node scripts/smoke.mjs",
40
- "verify:overrides": "node scripts/verify-review-overrides.mjs"
41
- },
42
- "files": [
43
- "scripts/req",
44
- "scripts/verify-review-overrides.mjs",
45
- "workflow/machine.schema.json",
46
- "workflow/req.config.schema.json",
47
- "workflow/review-persona.md",
48
- "bin",
49
- "templates",
50
- "AGENTS.template.md",
51
- "req.config.json.sample",
52
- "README.md",
53
- "README.en.md",
54
- "CHANGELOG.md"
55
- ],
56
- "engines": {
57
- "node": ">=18.17"
58
- },
59
- "dependencies": {
60
- "ajv": "^8.20.0",
61
- "cross-spawn": "^7.0.6",
62
- "semver": "^7.6.3",
63
- "tsx": "^4.19.1"
64
- },
65
- "devDependencies": {
66
- "@types/cross-spawn": "^6.0.6",
67
- "@types/node": "^22.7.4",
68
- "@types/semver": "^7.5.8",
69
- "typescript": "^5.6.2",
70
- "vitest": "^2.1.2"
71
- }
72
- }
1
+ {
2
+ "name": "commitgate",
3
+ "version": "0.8.0",
4
+ "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "sol5288",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sol5288/commitgate.git"
11
+ },
12
+ "homepage": "https://github.com/sol5288/commitgate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/sol5288/commitgate/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "code-review",
19
+ "workflow",
20
+ "codex",
21
+ "claude",
22
+ "git",
23
+ "commit-gate",
24
+ "fail-closed",
25
+ "review-gate",
26
+ "cli"
27
+ ],
28
+ "bin": {
29
+ "commitgate": "bin/commitgate.mjs"
30
+ },
31
+ "scripts": {
32
+ "req:new": "tsx scripts/req/req-new.ts",
33
+ "req:review-codex": "tsx scripts/req/review-codex.ts",
34
+ "req:doctor": "tsx scripts/req/req-doctor.ts",
35
+ "req:next": "tsx scripts/req/req-next.ts",
36
+ "req:commit": "tsx scripts/req/req-commit.ts",
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit",
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "verify:overrides": "node scripts/verify-review-overrides.mjs"
41
+ },
42
+ "files": [
43
+ "scripts/req",
44
+ "scripts/verify-review-overrides.mjs",
45
+ "workflow/machine.schema.json",
46
+ "workflow/req.config.schema.json",
47
+ "workflow/review-persona.md",
48
+ "bin",
49
+ "templates",
50
+ "skills",
51
+ "AGENTS.template.md",
52
+ "req.config.json.sample",
53
+ "README.md",
54
+ "README.en.md",
55
+ "CHANGELOG.md"
56
+ ],
57
+ "engines": {
58
+ "node": ">=18.17"
59
+ },
60
+ "dependencies": {
61
+ "ajv": "^8.20.0",
62
+ "cross-spawn": "^7.0.6",
63
+ "semver": "^7.6.3",
64
+ "tsx": "^4.19.1"
65
+ },
66
+ "devDependencies": {
67
+ "@types/cross-spawn": "^6.0.6",
68
+ "@types/node": "^22.7.4",
69
+ "@types/semver": "^7.5.8",
70
+ "typescript": "^5.6.2",
71
+ "vitest": "^2.1.2"
72
+ }
73
+ }
@@ -8,6 +8,7 @@
8
8
  "granularityMaxFiles": 8,
9
9
  "reviewModel": "gpt-5.6-terra",
10
10
  "reviewReasoningEffort": "high",
11
+ "reviewBudget": { "autoBudget": 5, "hardCap": 8 },
11
12
  "designDocs": {
12
13
  "requirement": "00-requirement.md",
13
14
  "design": "01-design.md",
@@ -27,6 +27,12 @@ export interface DesignDocs {
27
27
  */
28
28
  export type ReviewReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
29
29
 
30
+ /** review 예산(REQ-2026-028 A-2a). config↔review-codex 순환 방지를 위해 여기(config)에 정의. */
31
+ export interface ReviewBudget {
32
+ autoBudget: number
33
+ hardCap: number
34
+ }
35
+
30
36
  /** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
31
37
  export interface RawConfig {
32
38
  ticketRoot?: string
@@ -42,6 +48,8 @@ export interface RawConfig {
42
48
  reviewModel?: string | null
43
49
  /** codex 리뷰 추론강도(REQ-2026-013 P1). null = `-c model_reasoning_effort=` 생략. 미지정 = DEFAULTS. */
44
50
  reviewReasoningEffort?: ReviewReasoningEffort | null
51
+ /** REQ-2026-028 A-2a: review 예산. 미지정 = DEFAULTS(5/8). hardCap≤8·autoBudget≤hardCap은 loadConfig 검증. */
52
+ reviewBudget?: ReviewBudget
45
53
  }
46
54
 
47
55
  /** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
@@ -57,6 +65,7 @@ export interface ResolvedConfig {
57
65
  designDocs: DesignDocs
58
66
  reviewModel: string | null
59
67
  reviewReasoningEffort: ReviewReasoningEffort | null
68
+ reviewBudget: ReviewBudget
60
69
  // 파생(절대경로)
61
70
  workflowDirAbs: string
62
71
  schemaPathAbs: string
@@ -104,6 +113,8 @@ export const DEFAULTS = {
104
113
  // `as ... | null`은 handoffPath와 같은 이유(직접 import 소비자의 `| null` 계약 보존).
105
114
  reviewModel: 'gpt-5.6-terra' as string | null,
106
115
  reviewReasoningEffort: 'high' as ReviewReasoningEffort | null,
116
+ // REQ-2026-028 A-2a: review 예산. autoBudget=자동 허용 회차, hardCap=절대 상한(9번째 차단 → 8).
117
+ reviewBudget: { autoBudget: 5, hardCap: 8 } as ReviewBudget,
107
118
  }
108
119
 
109
120
  const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
@@ -125,6 +136,16 @@ export const CONFIG_SCHEMA = {
125
136
  reviewModel: { type: ['string', 'null'], pattern: BASENAME_RE },
126
137
  // effort는 실측 확정 enum(R15) + null. null을 enum에 포함해야 `{effort:null}`이 통과(JSON Schema enum은 타입 무관 전체 적용).
127
138
  reviewReasoningEffort: { type: ['string', 'null'], enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', null] },
139
+ // REQ-2026-028 A-2a: 예산. 스키마는 타입·상한(hardCap≤8·최소 1)까지. 교차검증(autoBudget≤hardCap)은 loadConfig.
140
+ reviewBudget: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ required: ['autoBudget', 'hardCap'],
144
+ properties: {
145
+ autoBudget: { type: 'integer', minimum: 1 },
146
+ hardCap: { type: 'integer', minimum: 1, maximum: 8 },
147
+ },
148
+ },
128
149
  designDocs: {
129
150
  type: 'object',
130
151
  additionalProperties: false,
@@ -209,8 +230,17 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
209
230
  reviewModel: raw.reviewModel !== undefined ? raw.reviewModel : DEFAULTS.reviewModel,
210
231
  reviewReasoningEffort:
211
232
  raw.reviewReasoningEffort !== undefined ? raw.reviewReasoningEffort : DEFAULTS.reviewReasoningEffort,
233
+ reviewBudget: raw.reviewBudget ?? DEFAULTS.reviewBudget,
212
234
  }
213
235
 
236
+ // REQ-2026-028 R7: 교차검증(스키마가 표현 못 함). AJV가 이미 hardCap∈[1,8]·autoBudget≥1을 잡았고,
237
+ // 여기서 autoBudget ≤ hardCap을 강제(fail-closed). R4("9번째는 어떤 경로로도 차단")는 설정을 넘는 코드
238
+ // 상수 경계다 — hardCap>8은 스키마가 거부하므로 config 한 줄로 뚫을 수 없다.
239
+ if (merged.reviewBudget.autoBudget > merged.reviewBudget.hardCap)
240
+ throw new Error(
241
+ `req.config: reviewBudget.autoBudget(${merged.reviewBudget.autoBudget}) > hardCap(${merged.reviewBudget.hardCap}) — autoBudget는 hardCap 이하여야 한다`,
242
+ )
243
+
214
244
  // repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
215
245
  // handoffPath만 면제 — 형제 repo의 SSOT 문서를 읽는 **외부 참조**이기 때문.
216
246
  // reviewPersonaPath는 패키지가 배포하고 init이 repo 안에 까는 자원이라 schemaPath와 같은 축이다(REQ-2026-010 D2).
@@ -20,6 +20,7 @@ import {
20
20
  readPhases,
21
21
  archiveBaseName,
22
22
  isArchiveFileName,
23
+ isValidIsoInstant,
23
24
  type ApprovalEvidence,
24
25
  type ReviewKind,
25
26
  type WorkflowState,
@@ -34,7 +35,7 @@ let pkgManager: PackageManager = DEFAULTS.packageManager
34
35
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
35
36
  const SHA256_RE = /^[0-9a-f]{64}$/i
36
37
  const GIT_OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i // git OID: 40(SHA-1) 또는 64(SHA-256)
37
- const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/ // new Date().toISOString() 형식
38
+ // ISO 검증은 review-codex의 isValidIsoInstant(형식+달력 유효성) 재사용 달력 불가능 값 거부(REQ-2026-030).
38
39
  // evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
39
40
  const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
40
41
  const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
@@ -163,8 +164,8 @@ export function validateManifest(
163
164
  if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
164
165
  if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
165
166
  if (typeof e.consumed_by_commit_sha !== 'string' || !GIT_OID_RE.test(e.consumed_by_commit_sha)) problems.push(`line ${ln}: consumed_by_commit_sha 비-OID`)
166
- if (typeof e.approved_at !== 'string' || !ISO_RE.test(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
167
- if (typeof e.consumed_at !== 'string' || !ISO_RE.test(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
167
+ if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
168
+ if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
168
169
  // kind별 strict 바인딩(반대 kind 필드 금지).
169
170
  if (kind === 'phase') {
170
171
  if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
@@ -228,7 +229,7 @@ export function userConfirmProblem(ucc: unknown): string | null {
228
229
  const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
229
230
  if (c.confirmed !== true) return 'confirmed=true 아님'
230
231
  if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
231
- if (typeof c.confirmed_at !== 'string' || !ISO_RE.test(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
232
+ if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
232
233
  return null
233
234
  }
234
235
 
@@ -7,12 +7,14 @@
7
7
  * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
8
  * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
9
9
  *
10
- * 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."] (저장소 패키지매니저의 실행 형식으로)
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).
11
13
  */
12
14
  import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
13
15
  import { join, relative } from 'node:path'
14
16
  import { pathToFileURL } from 'node:url'
15
- import { writeState, type WorkflowState } from './review-codex'
17
+ import { writeState, loadState, resolveSuccessorLineage, type WorkflowState, type SuccessorOf } from './review-codex'
16
18
  import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
17
19
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
18
20
  import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
@@ -69,7 +71,7 @@ export function nextStepHint(pm: PackageManager, reqId: string): string {
69
71
  return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
70
72
  }
71
73
 
72
- export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH'): WorkflowState {
74
+ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH', successorOf?: SuccessorOf): WorkflowState {
73
75
  return {
74
76
  id: reqId,
75
77
  branch,
@@ -87,7 +89,12 @@ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | '
87
89
  phases: [],
88
90
  // REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
89
91
  approval_evidence_required: true,
90
- }
92
+ // REQ-2026-027 D1: review series 모델 버전. 첫 리뷰 전에도 존재해 "새 ticket(레코드 없음)"과
93
+ // "legacy(필드 부재)"를 구분한다. 필드 부재 = legacy → 새 재리뷰 시 AWAIT_HUMAN/throw(자동 초기화 금지).
94
+ review_series_model_version: 1,
95
+ // REQ-2026-029 D3: 대체 REQ면 부모 lineage(--successor-of). 빈 review_series로 새 예산 — 부모 이력만 보존.
96
+ ...(successorOf ? { successor_of: successorOf } : {}),
97
+ } as WorkflowState
91
98
  }
92
99
 
93
100
  function listExistingReqIds(workflowDir: string): string[] {
@@ -103,11 +110,12 @@ export interface Opts {
103
110
  title: string | null
104
111
  run: boolean
105
112
  root: string | null
113
+ successorOf: string | null // REQ-2026-029 D3: 부모 REQ id(대체 REQ lineage)
106
114
  }
107
115
 
108
116
  /** 인자 파싱(fail-closed): 잘못된 --risk·값 누락·알 수 없는 옵션은 즉시 throw(조용한 fallback 금지). */
109
117
  export function parseArgs(argv: string[]): Opts {
110
- const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null }
118
+ const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null, successorOf: null }
111
119
  for (let i = 0; i < argv.length; i++) {
112
120
  const a = argv[i]
113
121
  if (a === undefined) continue
@@ -120,6 +128,10 @@ export function parseArgs(argv: string[]): Opts {
120
128
  const v = argv[++i]
121
129
  if (v === undefined) throw new Error('--root 값 필요')
122
130
  o.root = v
131
+ } else if (a === '--successor-of') {
132
+ const v = argv[++i]
133
+ if (v === undefined) throw new Error('--successor-of 값 필요(부모 REQ id)')
134
+ o.successorOf = v
123
135
  } else if (a === '--risk') {
124
136
  const v = argv[++i]
125
137
  if (v !== 'LOW' && v !== 'HIGH')
@@ -156,12 +168,29 @@ export function main(argv: string[] = process.argv.slice(2)): void {
156
168
  const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
157
169
  const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
158
170
 
171
+ // REQ-2026-029 D3: --successor-of lineage 해소. **branch 생성·mkdir 前**에 검증(design-r01 observation) —
172
+ // 부모 없음·replace 기록 없음·형식 위반이면 여기서 throw해 티켓이 생성되지 않는다(R6 fail-closed).
173
+ let successorOf: SuccessorOf | undefined
174
+ if (o.successorOf !== null) {
175
+ const parentId = o.successorOf.startsWith('REQ-') ? o.successorOf : `REQ-${o.successorOf}`
176
+ const parentDir = join(cfg.workflowDirAbs, parentId)
177
+ let parentState: WorkflowState
178
+ try {
179
+ parentState = loadState(parentDir)
180
+ } catch {
181
+ throw new Error(`--successor-of ${parentId}: 부모 티켓 state를 읽을 수 없다(${parentDir})`)
182
+ }
183
+ // recorded_at은 자식 생성 시각(부모 값 아님). dry-run에선 검증만 하고 값은 버린다.
184
+ successorOf = resolveSuccessorLineage(parentState, parentId, new Date().toISOString())
185
+ }
186
+
159
187
  if (!o.run) {
160
188
  console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
161
189
  console.log(` REQ : ${reqId}`)
162
190
  console.log(` branch : ${branch}`)
163
191
  console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
164
192
  console.log(` risk : ${o.risk}`)
193
+ if (successorOf) console.log(` successor_of : ${successorOf.req_id} (부모 ${successorOf.parent_attempts_total}회 · replace 승인 확인)`)
165
194
  return
166
195
  }
167
196
 
@@ -182,7 +211,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
182
211
 
183
212
  git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
184
213
  mkdirSync(ticketDir, { recursive: true })
185
- writeState(ticketDir, buildInitialState(reqId, branch, o.risk))
214
+ writeState(ticketDir, buildInitialState(reqId, branch, o.risk, successorOf))
186
215
  writeFileSync(join(ticketDir, dd.requirement), `# ${reqId} 요구사항\n\n${o.title ?? '(요구사항 작성)'}\n`, 'utf8')
187
216
  // DEC-WF-027 design-first: design·plan 스캐폴드도 함께 생성 — 첫 --kind design 리뷰가 문서 누락으로 fail-closed 되지 않게.
188
217
  writeFileSync(
@@ -29,10 +29,14 @@ import {
29
29
  captureDesignBinding,
30
30
  captureIndexHash,
31
31
  findUnstagedOrUntracked,
32
+ isLegacyTicket,
33
+ openSeriesAttempts,
34
+ isSeriesKeyTerminal,
32
35
  type WorkflowState,
33
36
  type ReviewKind,
34
37
  type LastReviewMarker,
35
38
  } from './review-codex'
39
+ import type { ReviewBudget } from './lib/config'
36
40
 
37
41
  // ─────────────────────────────────────────────── 읽기 전용 git 경계 (D6-1) ──
38
42
 
@@ -130,6 +134,8 @@ export interface NextInput {
130
134
  worktreeReviewClean: boolean
131
135
  /** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. */
132
136
  currentIndexHash: string | null
137
+ /** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
138
+ reviewBudget: ReviewBudget
133
139
  }
134
140
 
135
141
  /** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
@@ -355,6 +361,48 @@ function gateRunCandidate(input: NextInput, cand: RunCandidate): NextAction {
355
361
  '워킹트리에 unstaged/untracked 변경이 있어 리뷰(D10)가 실패한다. 의도한 변경은 `git add`, 그 외는 정리한 뒤 다시 req:next.',
356
362
  }
357
363
 
364
+ // terminal (REQ-2026-029 A-2b): human-resolution으로 종결된 키 → AWAIT_HUMAN. **G3보다 앞**(R4) — 종결된
365
+ // series는 예산 안내("고치고 예외 받으라")가 아니라 "이미 끝났다 — 대체 REQ" 안내가 맞다. G1보다는 뒤
366
+ // (dirty면 정리 먼저). 우선순위: G1 → terminal → G3 → G2.
367
+ if (isSeriesKeyTerminal(input.state, cand.kind, cand.phaseId))
368
+ return {
369
+ kind: 'AWAIT_HUMAN',
370
+ detail: '이 series는 human-resolution으로 종결됐다. 같은 키에서 자동으로 재개하지 않는다.',
371
+ controlPoint: 'human-resolution 종결됨',
372
+ approvalSentence: '대체가 필요하면 `req:new --successor-of <이 REQ>`로 만든다(종결 상태 유지 결정도 사람이 한다)',
373
+ diagnostics: ['종결 사유: human-resolution', '재개는 자동으로 일어나지 않는다 — 대체 REQ 또는 종결 유지.'],
374
+ }
375
+
376
+ // G3 (REQ-2026-028 A-2a): 자동 예산 소진(escalated) → AWAIT_HUMAN. **G2보다 앞**(R13) — 5회차 NEEDS_FIX
377
+ // 직후엔 escalated와 같은 바인딩 needs-fix가 동시 성립하는데, G2가 먼저 "findings 고치고 다시 add"(AGENT)를
378
+ // 내면 그 조언이 거짓이다(고쳐도 사람 승인 없이 6회차가 안 열린다). escalated는 파생값(저장 안 함, R11).
379
+ const openAttempts = openSeriesAttempts(input.state, cand.kind, cand.phaseId)
380
+ const { autoBudget, hardCap } = input.reviewBudget
381
+ if (openAttempts >= autoBudget) {
382
+ const nextAttempt = openAttempts + 1
383
+ const lrOutcome = (input.state.last_review as LastReviewMarker | undefined)?.outcome ?? '(없음)'
384
+ const hardBlocked = openAttempts >= hardCap
385
+ // ⚠️ "위험 수용"은 어느 문구에도 넣지 않는다(배분표 ④ — 부정문으로도 금지). 긍정 선택지만 나열.
386
+ const options = hardBlocked
387
+ ? '예외로도 진행 불가 — 종료하거나 정합한 대체 REQ를 작성한다.'
388
+ : '사람 승인 시 1회 예외 가능(review_exception_confirmed) · 종료 · 정합한 대체 REQ 작성.'
389
+ return {
390
+ kind: 'AWAIT_HUMAN',
391
+ detail: hardBlocked
392
+ ? `이 series는 하드 상한(hardCap=${hardCap})에 도달했다. ${nextAttempt}회차는 어떤 경로로도 실행하지 않는다.`
393
+ : `이 series는 자동 예산(autoBudget=${autoBudget})을 소진했다. ${nextAttempt}회차는 사람 결정이 필요하다.`,
394
+ controlPoint: 'review 예산 소진(escalated)',
395
+ approvalSentence: hardBlocked
396
+ ? 'review 하드 상한 도달 — 종료 또는 대체 REQ 작성(둘 중 하나를 사람이 결정)'
397
+ : `review ${nextAttempt}회차 예외 승인(또는 종료·대체 REQ 작성)`,
398
+ diagnostics: [
399
+ `series 시도 수(openAttempts)=${openAttempts} · 다음 회차=${nextAttempt}`,
400
+ `직전 리뷰 outcome=${lrOutcome}`,
401
+ `선택지: ${options}`,
402
+ ],
403
+ }
404
+ }
405
+
358
406
  // G2
359
407
  const lr = input.state.last_review as LastReviewMarker | undefined
360
408
  const sameTarget =
@@ -438,6 +486,18 @@ export function resolveNext(input: NextInput): NextAction {
438
486
  approvalSentence: 'req:commit --run 승인',
439
487
  }
440
488
 
489
+ // 1.5 legacy ticket(REQ-2026-027 D1): 모델 버전 부재 = legacy. 살아 있는 승인(1번)보다는 뒤 —
490
+ // 그건 소비만 하면 되고 새 외부 호출이 아니다. design/phase RUN 후보(2·3번)보다는 **앞** — 그 후보를
491
+ // 내면 사용자가 실행한 뒤에야 호출 지점에서 throw된다(R2는 AWAIT_HUMAN을 요구). 자동 초기화하지 않는다.
492
+ if (isLegacyTicket(state))
493
+ return {
494
+ kind: 'AWAIT_HUMAN',
495
+ detail:
496
+ 'legacy ticket(review_series_model_version 부재)이다. 자동으로 새 모델로 초기화하지 않는다 — 사람이 이 티켓을 새 series 모델로 채택할지 결정해야 한다.',
497
+ controlPoint: 'legacy 티켓 채택',
498
+ approvalSentence: 'state.json에 review_series_model_version: 1 추가(이 티켓을 새 모델로 채택) 승인',
499
+ }
500
+
441
501
  // 2. 설계 문서가 인덱스에 없으면 3번의 freshness 판정(captureDesignBinding)이 throw한다. 여기서 먼저 거른다.
442
502
  if (!input.designDocsInIndex)
443
503
  return {
@@ -631,6 +691,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
631
691
  hasStagedChanges: roGit(['diff', '--cached', '--name-only']).trim().length > 0,
632
692
  worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
633
693
  currentIndexHash: captureIndexHash(roGit),
694
+ reviewBudget: cfg.reviewBudget,
634
695
  })
635
696
 
636
697
  if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))