commitgate 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/migrate.ts ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * commitgate migrate — **Stage A(vendored scaffold) → Stage B(런타임 패키지)** 비파괴 전환 (REQ-2026-014 R5).
4
+ *
5
+ * 이 명령이 하는 일은 **하나뿐**이다: 대상 `package.json`의 `req:*` 중 **현재 값이 정확히 Stage A 주입값인 키만**
6
+ * `commitgate <verb>`로 바꾼다. 그 외에는 아무것도 하지 않는다.
7
+ *
8
+ * 비파괴 계약(PM 결정):
9
+ * - **아무것도 삭제하지 않는다.** `scripts/req/**`·schema·persona·`req.config.json`·진입점·`workflow/REQ-*` 증거를
10
+ * 자동 삭제하지 않는다. 정리는 읽기 전용 `commitgate uninstall` 계획 또는 `git revert` 안내로만.
11
+ * - **사용자 정의 script를 절대 덮어쓰지 않는다.** 정확한 Stage A 주입값이 아니면 보존하고 수동 조치를 안내한다.
12
+ * - **커밋하지 않는다.** 사용자가 검토 후 stage/commit 한다.
13
+ * - 기본은 **dry-run**(쓰기 0건). `--apply`에서만 쓴다.
14
+ *
15
+ * 쓰기 범위는 **`package.json` 한 파일**이다. 그래서 다중 파일 rollback 프레임워크가 필요 없다
16
+ * (REQ-2026-014 D11 제거와 일관 — 기존 init도 rollback이 없고 "쓰기 前 실패"가 계약이다).
17
+ *
18
+ * ⚠️ **동기(sync) 구현이어야 한다.** launcher(`bin/commitgate.mjs`)가 `mod.runCli(rest)`를 **await 없이** 호출하고
19
+ * 기존 7개 runCli가 전부 sync `void`다. async면 promise가 버려져 오류가 unhandledRejection이 되고 exit code가 소실된다.
20
+ *
21
+ * ⚠️ **대상 root는 `--dir`(기본 cwd)로만 해소한다.** `resolveRoot`(scripts/req/lib/config.ts)는 config를 못 찾으면
22
+ * fallback으로 **패키지 자신의 root**를 반환한다 — package.json을 쓰는 이 명령이 그 fallback을 타면
23
+ * CommitGate 패키지 자신의 package.json을 재작성한다. init·uninstall과 같은 `--dir` 방식을 쓴다.
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs'
26
+ import { resolve, join } from 'node:path'
27
+ import { pathToFileURL } from 'node:url'
28
+ import { stripBom } from '../scripts/req/lib/config'
29
+ import { REQ_SCRIPTS, STAGE_B_REQ_SCRIPTS, KIT_SOURCE_DIR_REL, assertGitWorkTree, commitgateDeclared } from './init'
30
+
31
+ export interface MigrateOptions {
32
+ dir: string
33
+ /** 기본 false = dry-run(쓰기 0건). true면 package.json 한 파일을 쓴다. */
34
+ apply: boolean
35
+ }
36
+
37
+ /** `req:*` 키 하나의 전환 판정. */
38
+ export interface ScriptDecision {
39
+ key: string
40
+ current: string | undefined
41
+ /** 'convert' = 정확한 Stage A 값 → 전환 대상. 'stage-b' = 이미 전환됨. 'custom' = 사용자 값(보존). 'absent' = 키 없음. */
42
+ kind: 'convert' | 'stage-b' | 'custom' | 'absent'
43
+ next: string | undefined
44
+ }
45
+
46
+ export interface MigratePlan {
47
+ targetRoot: string
48
+ decisions: ScriptDecision[]
49
+ /** 실제로 바꿀 키(kind==='convert'). 비어 있으면 쓸 것이 없다. */
50
+ converts: ScriptDecision[]
51
+ /** 보존하는 사용자 정의 키(kind==='custom') — 수동 조치 안내 대상. */
52
+ customs: ScriptDecision[]
53
+ /** vendored `scripts/req/**`가 남아 있는가(삭제하지 않는다 — 안내만). */
54
+ vendoredPresent: boolean
55
+ }
56
+
57
+ /**
58
+ * `req:*` 전환 판정(순수 — REQ-2026-014 D5).
59
+ *
60
+ * **정확한 바이트 일치일 때만 전환**한다. 이 술어는 `bin/uninstall.ts:295-303`의 `cur === injected`와 같은 계약이며
61
+ * `REQ_SCRIPTS`가 그 SSOT다. 사용자 정의 값(`req:new = "node custom.mjs"` 등)은 **절대 덮어쓰지 않는다**.
62
+ */
63
+ export function decideScripts(scripts: Record<string, string>): ScriptDecision[] {
64
+ return Object.keys(REQ_SCRIPTS).map((key) => {
65
+ const current = scripts[key]
66
+ const stageA = REQ_SCRIPTS[key]
67
+ const stageB = STAGE_B_REQ_SCRIPTS[key]
68
+ if (current === undefined) return { key, current, kind: 'absent' as const, next: undefined }
69
+ if (current === stageA) return { key, current, kind: 'convert' as const, next: stageB }
70
+ if (current === stageB) return { key, current, kind: 'stage-b' as const, next: undefined }
71
+ return { key, current, kind: 'custom' as const, next: undefined }
72
+ })
73
+ }
74
+
75
+ /** 대상을 읽어 계획을 만든다(**쓰기 0건**). */
76
+ export function planMigrate(opts: MigrateOptions): MigratePlan {
77
+ const targetRoot = resolve(opts.dir)
78
+ if (!existsSync(targetRoot) || !statSync(targetRoot).isDirectory())
79
+ throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
80
+ assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
81
+
82
+ const pkgPath = join(targetRoot, 'package.json')
83
+ if (!existsSync(pkgPath)) throw new Error(`package.json 없음: ${targetRoot}`)
84
+ const pkg = parsePkg(pkgPath)
85
+ const scripts = pkg.scripts ?? {}
86
+
87
+ const decisions = decideScripts(scripts)
88
+ return {
89
+ targetRoot,
90
+ decisions,
91
+ converts: decisions.filter((d) => d.kind === 'convert'),
92
+ customs: decisions.filter((d) => d.kind === 'custom'),
93
+ vendoredPresent: existsSync(join(targetRoot, KIT_SOURCE_DIR_REL)),
94
+ }
95
+ }
96
+
97
+ /** package.json 파싱(BOM 허용 — PowerShell `Set-Content -Encoding UTF8`). shape 검증 포함(fail-closed). */
98
+ function parsePkg(pkgPath: string): { scripts?: Record<string, string>; devDependencies?: Record<string, string> } {
99
+ let raw: unknown
100
+ try {
101
+ raw = JSON.parse(stripBom(readFileSync(pkgPath, 'utf8')))
102
+ } catch (err) {
103
+ throw new Error(`package.json 파싱 실패(${pkgPath}): ${err instanceof Error ? err.message : String(err)}`)
104
+ }
105
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`package.json이 JSON 객체가 아님: ${pkgPath}`)
106
+ for (const field of ['scripts', 'devDependencies'] as const) {
107
+ const v = (raw as Record<string, unknown>)[field]
108
+ if (v !== undefined && (typeof v !== 'object' || v === null || Array.isArray(v)))
109
+ throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — 배열/원시 미지원.`)
110
+ }
111
+ return raw as { scripts?: Record<string, string>; devDependencies?: Record<string, string> }
112
+ }
113
+
114
+ /** 계획을 사람이 읽는 줄 배열로. **shell 연산자(`&&`)를 쓰지 않는다**(Windows PowerShell 5.1·cmd.exe 비호환 — DEC-011-8). */
115
+ export function renderPlan(plan: MigratePlan, apply: boolean): string[] {
116
+ const L: string[] = []
117
+ L.push('')
118
+ L.push(`[commitgate migrate] Stage A → Stage B 전환 ${apply ? '(--apply: package.json 을 씁니다)' : '계획 (dry-run — 아무것도 쓰지 않습니다)'}`)
119
+ L.push(` 대상: ${plan.targetRoot}`)
120
+ L.push('')
121
+
122
+ if (plan.converts.length > 0) {
123
+ L.push(` 전환 대상 — 현재 값이 정확히 Stage A 주입값인 키 ${plan.converts.length}개:`)
124
+ for (const d of plan.converts) L.push(` package.json#scripts.${d.key}: "${d.current}" → "${d.next}"`)
125
+ } else {
126
+ L.push(' 전환 대상 없음 — 정확한 Stage A 주입값인 req:* 키가 없습니다.')
127
+ }
128
+ L.push('')
129
+
130
+ const stageB = plan.decisions.filter((d) => d.kind === 'stage-b')
131
+ if (stageB.length > 0) L.push(` 이미 Stage B: ${stageB.map((d) => d.key).join(', ')}`)
132
+
133
+ if (plan.customs.length > 0) {
134
+ L.push('')
135
+ L.push(' ⚠️ 사용자 정의 값 — **덮어쓰지 않습니다**. 수동 조치가 필요합니다:')
136
+ for (const d of plan.customs) L.push(` package.json#scripts.${d.key} = "${d.current}"`)
137
+ L.push(' Stage B 런타임을 쓰려면 그 스크립트가 `commitgate <verb>` 를 호출하도록 직접 고치십시오.')
138
+ }
139
+
140
+ if (plan.vendoredPresent) {
141
+ L.push('')
142
+ L.push(` ℹ️ ${KIT_SOURCE_DIR_REL}/ 가 남아 있습니다 — migrate 는 **아무것도 삭제하지 않습니다**(비파괴).`)
143
+ L.push(' Stage B 에서 실행 코드는 node_modules/commitgate 에서 돕니다. 남은 vendored 파일은 사용하지 않습니다.')
144
+ L.push(' 정리하려면 먼저 읽기 전용 계획을 보십시오: npx commitgate uninstall')
145
+ }
146
+
147
+ L.push('')
148
+ if (!apply) {
149
+ L.push(' 적용하려면: npx commitgate migrate --apply')
150
+ L.push(' (--apply 는 package.json 만 씁니다. 커밋하지 않습니다 — 직접 검토 후 stage/commit 하십시오.)')
151
+ } else if (plan.converts.length > 0) {
152
+ L.push(' 다음: git diff package.json 으로 확인한 뒤 커밋하십시오.')
153
+ L.push(' git add -- package.json')
154
+ L.push(' git commit -m "chore: migrate commitgate to Stage B runtime"')
155
+ }
156
+ return L
157
+ }
158
+
159
+ /**
160
+ * 실행. 기본 dry-run(쓰기 0건), `--apply`에서만 `package.json` 한 파일을 쓴다.
161
+ *
162
+ * `--apply` 전 **선행 설치 확인**: Stage B 스크립트(`commitgate <verb>`)를 심으므로 대상에 commitgate가
163
+ * devDependency로 있어야 한다. init의 D14와 **같은 축소 규칙** — **키 존재만** 보고 값 형태는 검증하지 않는다
164
+ * (`npm i -D <tgz>`는 `file:…tgz`를 쓴다).
165
+ */
166
+ export function runMigrate(opts: MigrateOptions): MigratePlan {
167
+ const plan = planMigrate(opts)
168
+
169
+ if (opts.apply) {
170
+ const pkgPath = join(plan.targetRoot, 'package.json')
171
+ const pkg = parsePkg(pkgPath)
172
+ if (!commitgateDeclared(pkg.devDependencies ?? {}))
173
+ throw new Error(
174
+ `devDependencies.commitgate 선언이 없습니다 — Stage B 는 req:* 를 'commitgate <verb>' 로 심으므로 ` +
175
+ `대상에 commitgate 가 devDependency 로 있어야 합니다. 먼저 'npm install -D commitgate' 를 실행한 뒤 다시 시도하십시오.`,
176
+ )
177
+ if (plan.converts.length > 0) {
178
+ const scripts = pkg.scripts ?? {}
179
+ for (const d of plan.converts) if (d.next !== undefined) scripts[d.key] = d.next
180
+ pkg.scripts = scripts
181
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
182
+ }
183
+ }
184
+
185
+ for (const line of renderPlan(plan, opts.apply)) console.log(line)
186
+ return plan
187
+ }
188
+
189
+ /** CLI 파싱(fail-closed). 기존 `bin/init.ts`·`bin/uninstall.ts` 관례를 따른다 — `--flag=value` 미지원, 미지 토큰은 throw. */
190
+ export function parseArgs(argv: string[]): MigrateOptions {
191
+ let dir = process.cwd()
192
+ let apply = false
193
+ for (let i = 0; i < argv.length; i++) {
194
+ const a = argv[i]
195
+ if (a === '--dir') {
196
+ const v = argv[i + 1]
197
+ if (v === undefined) throw new Error('--dir 값 누락')
198
+ dir = v
199
+ i++
200
+ } else if (a === '--apply') {
201
+ apply = true
202
+ } else if (a === '--dry-run') {
203
+ apply = false // 기본값이지만 명시 허용(문서화된 의도 표현)
204
+ } else if (a === '-h' || a === '--help') {
205
+ printHelp()
206
+ process.exit(0)
207
+ } else {
208
+ throw new Error(`알 수 없는 인자: ${a}`)
209
+ }
210
+ }
211
+ return { dir: resolve(dir), apply }
212
+ }
213
+
214
+ function printHelp(): void {
215
+ console.log(`commitgate migrate — Stage A(vendored) → Stage B(런타임 패키지) 비파괴 전환
216
+
217
+ 사용법:
218
+ npx commitgate migrate [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
219
+ npx commitgate migrate --apply [--dir <대상repo>] package.json 의 req:* 전환
220
+
221
+ 하는 일:
222
+ package.json 의 req:* 중 **현재 값이 정확히 Stage A 주입값인 키만** "commitgate <verb>" 로 바꿉니다.
223
+
224
+ 하지 않는 일:
225
+ - 아무것도 삭제하지 않습니다(scripts/req/**·schema·persona·config·진입점·workflow 증거 전부 보존).
226
+ - 사용자 정의 script 를 덮어쓰지 않습니다(보존 + 수동 조치 안내).
227
+ - 커밋하지 않습니다.
228
+
229
+ --apply 전제:
230
+ 대상 package.json 에 devDependencies.commitgate 선언이 있어야 합니다(먼저 'npm install -D commitgate').
231
+ `)
232
+ }
233
+
234
+ export function runCli(argv: string[]): void {
235
+ try {
236
+ runMigrate(parseArgs(argv))
237
+ } catch (err) {
238
+ console.error(`commitgate migrate: ${err instanceof Error ? err.message : String(err)}`)
239
+ process.exitCode = 1
240
+ }
241
+ }
242
+
243
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
244
+ if (isMain) runCli(process.argv.slice(2))
package/bin/uninstall.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  KIT_SCHEMA_RELPATHS,
30
30
  KIT_COPY_RELPATHS,
31
31
  KIT_AGENT_ENTRYPOINTS,
32
+ KIT_GITIGNORE,
32
33
  KIT_CLAUDE_TEMPLATE_REL,
33
34
  KIT_CLAUDE_DEST_REL,
34
35
  KIT_AGENTS_CONTRACT_COPY_REL,
@@ -96,6 +97,13 @@ export interface UninstallFacts {
96
97
  * 사용자가 직접 넣은 파일일 수 있으므로 제거 후보에 넣지 않고, 디렉터리 통삭제도 제안하지 않는다.
97
98
  */
98
99
  unknownKitFiles: string[]
100
+ /**
101
+ * 대상 `package.json`의 `devDependencies.commitgate` **선언값**(없으면 null) — REQ-2026-014 R4.
102
+ *
103
+ * Stage B 런타임 제거 안내(`npm uninstall -D commitgate`)를 낼지 판정하는 데만 쓴다. **진단용 표시**이고
104
+ * 이 값으로 무엇을 삭제하지 않는다. 값의 형태는 검증하지 않는다(`file:…tgz`·`workspace:*` 전부 정당한 설치 형태).
105
+ */
106
+ commitgateDevDependency: string | null
99
107
  }
100
108
 
101
109
  export type UninstallMode = 'not-installed' | 'uncommitted' | 'committed' | 'mixed'
@@ -220,6 +228,8 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
220
228
  ...KIT_AGENT_ENTRYPOINTS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
221
229
  // 마커 없는 AGENTS.md 경로에서만 설치되는 계약 템플릿 사본. 부재가 정상이므로 present=false여도 문제 없다.
222
230
  { destRel: KIT_AGENTS_CONTRACT_COPY_REL, srcRel: 'AGENTS.template.md' },
231
+ // workflow/.gitignore(REQ-2026-012): src≠dest. identical이면 제거 대상, differs면 review(사용자 편집 보존). 부재 정상.
232
+ { destRel: KIT_GITIGNORE.dest, srcRel: KIT_GITIGNORE.src },
223
233
  ]
224
234
  const tool: ToolArtifact[] = toolEntries.map(({ destRel, srcRel }) => {
225
235
  const dest = join(targetRoot, destRel)
@@ -335,6 +345,9 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
335
345
  const packageJsonDirty = existsSync(pkgAbs) && git.exec(['status', '--porcelain', '--', 'package.json']).length > 0
336
346
  const installed = tool.some((t) => t.present) || ambiguous.some((a) => a.present)
337
347
 
348
+ // Stage B 런타임 제거 안내용(REQ-2026-014 R4) — 선언값 표시만. 값 형태는 검증하지 않는다.
349
+ const commitgateDevDependency = devDeps['commitgate'] ?? null
350
+
338
351
  return {
339
352
  targetRoot,
340
353
  ticketRoot,
@@ -347,6 +360,7 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
347
360
  evidence,
348
361
  info,
349
362
  unknownKitFiles,
363
+ commitgateDevDependency,
350
364
  }
351
365
  }
352
366
 
@@ -461,7 +475,11 @@ export function renderPlan(plan: UninstallPlan): string {
461
475
  L.push(...renderRevertSection(plan))
462
476
  L.push('')
463
477
 
464
- L.push('## 5. 잔여물 경고')
478
+ L.push('## 5. 런타임 패키지 제거 (Stage B)')
479
+ L.push(...renderRuntimeRemovalSection(plan))
480
+ L.push('')
481
+
482
+ L.push('## 6. 잔여물 경고')
465
483
  L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
466
484
  L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
467
485
  L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
@@ -471,6 +489,30 @@ export function renderPlan(plan: UninstallPlan): string {
471
489
  return L.join('\n')
472
490
  }
473
491
 
492
+ /**
493
+ * **Stage B 런타임 제거 안내**(REQ-2026-014 R4). Stage B에서 실행 코드는 이 저장소가 아니라
494
+ * `node_modules/commitgate`에 있으므로, 런타임 제거는 **package manager의 책임**이다.
495
+ *
496
+ * ⚠️ **문자열로 출력만 한다 — npm을 spawn하지 않는다.** 이 파일의 읽기 전용 불변식이다(`node:fs` 조회 API만 쓰고
497
+ * `node:child_process`를 import하지 않는다). 이 안내를 실제 실행으로 바꾸지 말 것.
498
+ */
499
+ function renderRuntimeRemovalSection(plan: UninstallPlan): string[] {
500
+ const declared = plan.facts.commitgateDevDependency
501
+ const L: string[] = []
502
+ if (declared !== null) {
503
+ L.push(` package.json 에 devDependencies.commitgate = "${declared}" 가 선언돼 있습니다(Stage B 런타임).`)
504
+ L.push(' 실행 코드(scripts/req/**)는 이 저장소가 아니라 node_modules/commitgate 에 있습니다.')
505
+ L.push(' 런타임 제거는 package manager 가 담당합니다 — 이 명령은 실행하지 않습니다. 직접 실행하세요:')
506
+ L.push(' npm uninstall -D commitgate (pnpm: pnpm remove -D commitgate · yarn: yarn remove commitgate)')
507
+ L.push(' 그러면 devDependencies 선언 · node_modules · lockfile 항목이 함께 정리됩니다.')
508
+ } else {
509
+ L.push(' devDependencies.commitgate 선언이 없습니다 — Stage B 런타임 패키지로 설치된 상태가 아닙니다.')
510
+ L.push(' (Stage A vendored 설치본이거나 `npx commitgate` 일회 실행인 경우입니다 — 아래 npx 캐시 항목 참고.)')
511
+ }
512
+ L.push(' package.json 의 req:* 스크립트는 **수동 정리 후보**입니다 — 이 명령은 고치지 않습니다(2번 항목 참조).')
513
+ return L
514
+ }
515
+
474
516
  function renderRevertSection(plan: UninstallPlan): string[] {
475
517
  const L: string[] = []
476
518
  const { facts } = plan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitgate",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,10 +36,12 @@
36
36
  "req:commit": "tsx scripts/req/req-commit.ts",
37
37
  "test": "vitest run",
38
38
  "typecheck": "tsc --noEmit",
39
- "smoke": "node scripts/smoke.mjs"
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "verify:overrides": "node scripts/verify-review-overrides.mjs"
40
41
  },
41
42
  "files": [
42
43
  "scripts/req",
44
+ "scripts/verify-review-overrides.mjs",
43
45
  "workflow/machine.schema.json",
44
46
  "workflow/req.config.schema.json",
45
47
  "workflow/review-persona.md",
@@ -48,7 +50,8 @@
48
50
  "AGENTS.template.md",
49
51
  "req.config.json.sample",
50
52
  "README.md",
51
- "README.en.md"
53
+ "README.en.md",
54
+ "CHANGELOG.md"
52
55
  ],
53
56
  "engines": {
54
57
  "node": ">=18.17"
@@ -6,6 +6,8 @@
6
6
  "branchPrefix": "feat/req-",
7
7
  "packageManager": "npm",
8
8
  "granularityMaxFiles": 8,
9
+ "reviewModel": "gpt-5.6-terra",
10
+ "reviewReasoningEffort": "high",
9
11
  "designDocs": {
10
12
  "requirement": "00-requirement.md",
11
13
  "design": "01-design.md",
@@ -76,6 +76,10 @@ export interface ReviewRequest {
76
76
  schemaPath: string
77
77
  resumeThreadId: string | null
78
78
  cwd: string
79
+ /** REQ-2026-013 P1: codex `-c model=` override. null = 생략(전역 상속). */
80
+ model: string | null
81
+ /** REQ-2026-013 P1: codex `-c model_reasoning_effort=` override. null = 생략. */
82
+ reasoningEffort: string | null
79
83
  }
80
84
  export interface ReviewResult {
81
85
  rawStdout: string
@@ -109,18 +113,55 @@ const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
109
113
  // ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId 등)의 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 — P1 수정.
110
114
  safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
111
115
 
116
+ /** unknown → 평범한 객체(배열·null 제외). 스키마 경로 탐색용. */
117
+ function asPlainObject(v: unknown): Record<string, unknown> | null {
118
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
119
+ }
120
+
121
+ /**
122
+ * 출력 스키마의 `findings[].severity`를 **P1만** 허용하도록 좁힌다(REQ-2026-018 D2).
123
+ *
124
+ * **왜 출력 스키마에서만 강제하는가(D1)**: 검증 SSOT(`machine.schema.json`)의 enum을 P1로 좁히면 P2/P3를 담은
125
+ * **기존 archive가 전부 invalid**가 된다(하위호환 파괴). 반면 목표는 "리뷰어가 P2를 findings에 **낼 수 없게**"이므로
126
+ * 리뷰어가 실제로 보는 출력 스키마 한 곳만 좁히면 충분하다. 그러면 비차단 지적은 `observations`로 갈 수밖에 없고,
127
+ * `classifyReview`의 "findings 있으면 차단"은 고칠 필요 없이 그대로 옳아진다.
128
+ *
129
+ * **왜 조용히 건너뛰지 않고 throw하는가(D3)**: 건너뛰면 P2가 다시 차단 채널로 들어오는 **정책 구멍**이 열리는데,
130
+ * 그 구멍은 스키마가 깨진 순간에만 열려 아무도 눈치채지 못한다. throw는 리뷰 실패 = 승인 불가 = fail-closed.
131
+ * `machine_schema_version`이 `1.1`로 고정된 MANAGED 파일이므로 정상 경로에서 이 throw는 발생하지 않는다.
132
+ */
133
+ function narrowFindingsSeverityToP1(schema: Record<string, unknown>): void {
134
+ const properties = asPlainObject(schema.properties)
135
+ const findings = asPlainObject(properties?.findings)
136
+ const items = asPlainObject(findings?.items)
137
+ const itemProps = asPlainObject(items?.properties)
138
+ const severity = asPlainObject(itemProps?.severity)
139
+ if (!severity || !Array.isArray(severity.enum)) {
140
+ throw new Error(
141
+ '출력 스키마 파생 실패: `properties.findings.items.properties.severity.enum` 경로가 없음 — ' +
142
+ 'findings를 P1 전용(차단)으로 강제할 수 없어 중단합니다(REQ-2026-018 D3, fail-closed).',
143
+ )
144
+ }
145
+ severity.enum = ['P1']
146
+ }
147
+
112
148
  /**
113
- * Codex `--output-schema`용 **strict copy** 파생(REQ-2026-005). OpenAI structured-outputs strict mode는
114
- * root `required`가 `properties`의 **모든 키**를 포함해야 한다 optional 필드(예: observations)가 있으면 400 invalid_json_schema.
115
- * 원본 스키마(`workflow/machine.schema.json`)는 **검증 SSOT로 불변**(observations optional → 기존 archive 하위호환)이고,
116
- * codex 호출 직전에만 root.required를 `properties` 전체로 확장한 copy를 파생한다. 응답/archive 검증은 계속 원본으로 한다.
117
- * 중첩 객체(findings/observations items) 이미 모든 필드 required + additionalProperties:false라 root 확장하면 충분.
149
+ * Codex `--output-schema`용 **strict copy** 파생(REQ-2026-005 + REQ-2026-018). 원본 스키마
150
+ * (`workflow/machine.schema.json`)는 **검증 SSOT로 불변**이고, codex 호출 직전에만 아래 가지를 적용한 copy를 파생한다.
151
+ * 응답/archive 검증은 계속 원본으로 한다.
152
+ *
153
+ * 1. **root `required` = `properties` 전체 키**(REQ-2026-005) OpenAI structured-outputs strict mode는 root required가
154
+ * properties의 모든 키를 포함해야 한다. optional 필드(예: observations)가 있으면 400 invalid_json_schema.
155
+ * 중첩 객체(findings/observations items)는 이미 모든 필드 required + additionalProperties:false라 root만 확장하면 충분.
156
+ * 2. **`findings[].severity` = `["P1"]`**(REQ-2026-018) — 차단 채널에 P2/P3가 들어오지 못하게 구조적으로 막는다.
157
+ * 상세 근거는 `narrowFindingsSeverityToP1` 참조.
118
158
  */
119
159
  export function deriveStrictOutputSchema(schemaText: string): string {
120
160
  const schema = JSON.parse(schemaText) as { properties?: Record<string, unknown>; required?: string[]; [k: string]: unknown }
121
161
  if (schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object') {
122
162
  schema.required = Object.keys(schema.properties)
123
163
  }
164
+ narrowFindingsSeverityToP1(schema)
124
165
  return JSON.stringify(schema)
125
166
  }
126
167
 
@@ -136,15 +177,22 @@ export function deriveStrictOutputSchema(schemaText: string): string {
136
177
  */
137
178
  export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner): ReviewerAdapter {
138
179
  return {
139
- review({ prompt, schemaPath, resumeThreadId, cwd }) {
180
+ review({ prompt, schemaPath, resumeThreadId, cwd, model, reasoningEffort }) {
140
181
  const tmpDir = mkdtempSync(join(tmpdir(), 'req-codex-'))
141
182
  const lastPath = join(tmpDir, 'last.json')
142
183
  // 원본(검증 SSOT)을 읽어 strict copy 파생 → temp에 기록 → --output-schema로 전달(archive 검증엔 원본 사용).
143
184
  const outputSchemaPath = join(tmpDir, 'output-schema.json')
144
185
  writeFileSync(outputSchemaPath, deriveStrictOutputSchema(readFileSync(schemaPath, 'utf8')), 'utf8')
186
+ // REQ-2026-013 P1: 리뷰 모델·추론강도 override(D2·D2-1). null이면 생략(전역 상속).
187
+ // 값은 TOML 문자열 리터럴(`sandbox_mode="read-only"`와 동형). 주입 안전은 스키마 제약(model=slug 패턴·effort=enum)이
188
+ // `"`·개행을 입력단에서 차단하므로 조립부 escaping 불필요 — 이 안전은 config.ts CONFIG_SCHEMA에 의존한다.
189
+ const overrideArgs: string[] = []
190
+ if (model) overrideArgs.push('-c', `model="${model}"`)
191
+ if (reasoningEffort) overrideArgs.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
192
+ // exec·resume 양쪽에 동일 주입(codex `-c`는 두 서브커맨드 모두 받음 — 실측).
145
193
  const args = resumeThreadId
146
- ? ['exec', 'resume', resumeThreadId, '-c', 'sandbox_mode="read-only"', '--json', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
147
- : ['exec', '--json', '--sandbox', 'read-only', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
194
+ ? ['exec', 'resume', resumeThreadId, '-c', 'sandbox_mode="read-only"', ...overrideArgs, '--json', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
195
+ : ['exec', ...overrideArgs, '--json', '--sandbox', 'read-only', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
148
196
  const rawStdout = run(args, prompt, cwd)
149
197
  const threadId = resumeThreadId ?? parseThreadId(rawStdout)
150
198
  const lastMessage = existsSync(lastPath) ? readFileSync(lastPath, 'utf8') : ''
@@ -21,6 +21,12 @@ export interface DesignDocs {
21
21
  plan: string
22
22
  }
23
23
 
24
+ /**
25
+ * codex 리뷰어의 추론강도(REQ-2026-013 P1). 실측 확정(R15): codex의 invalid-effort 거부 메시지가
26
+ * `none|minimal|low|medium|high|xhigh`를 지원값으로 명시. `null`은 override 생략(전역 상속) 탈출구.
27
+ */
28
+ export type ReviewReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
29
+
24
30
  /** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
25
31
  export interface RawConfig {
26
32
  ticketRoot?: string
@@ -32,6 +38,10 @@ export interface RawConfig {
32
38
  packageManager?: PackageManager
33
39
  granularityMaxFiles?: number
34
40
  designDocs?: Partial<DesignDocs>
41
+ /** codex 리뷰 모델(REQ-2026-013 P1). null = `-c model=` 생략(전역 상속). 미지정 = DEFAULTS. */
42
+ reviewModel?: string | null
43
+ /** codex 리뷰 추론강도(REQ-2026-013 P1). null = `-c model_reasoning_effort=` 생략. 미지정 = DEFAULTS. */
44
+ reviewReasoningEffort?: ReviewReasoningEffort | null
35
45
  }
36
46
 
37
47
  /** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
@@ -45,6 +55,8 @@ export interface ResolvedConfig {
45
55
  packageManager: PackageManager
46
56
  granularityMaxFiles: number
47
57
  designDocs: DesignDocs
58
+ reviewModel: string | null
59
+ reviewReasoningEffort: ReviewReasoningEffort | null
48
60
  // 파생(절대경로)
49
61
  workflowDirAbs: string
50
62
  schemaPathAbs: string
@@ -87,6 +99,11 @@ export const DEFAULTS = {
87
99
  packageManager: 'pnpm' as PackageManager,
88
100
  granularityMaxFiles: 8,
89
101
  designDocs: { requirement: '00-requirement.md', design: '01-design.md', plan: '02-plan.md' } as DesignDocs,
102
+ // REQ-2026-013 P1: 리뷰어 모델·추론강도 고정. 코어 기본은 DEFAULTS 중립성의 의도적 예외(D3) —
103
+ // 리뷰어 모델은 게이트 무결성 핵심이라 미고정 시 전역 ultra 상속이 곧 결함. 미지원 CLI는 config override/null.
104
+ // `as ... | null`은 handoffPath와 같은 이유(직접 import 소비자의 `| null` 계약 보존).
105
+ reviewModel: 'gpt-5.6-terra' as string | null,
106
+ reviewReasoningEffort: 'high' as ReviewReasoningEffort | null,
90
107
  }
91
108
 
92
109
  const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
@@ -104,6 +121,10 @@ export const CONFIG_SCHEMA = {
104
121
  branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
105
122
  packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
106
123
  granularityMaxFiles: { type: 'integer', minimum: 1 },
124
+ // REQ-2026-013 P1. null=override 생략(전역 상속). model은 slug 패턴(따옴표·개행 거부 → TOML `model="…"` 주입 안전; null은 pattern에 vacuously 통과).
125
+ reviewModel: { type: ['string', 'null'], pattern: BASENAME_RE },
126
+ // effort는 실측 확정 enum(R15) + null. null을 enum에 포함해야 `{effort:null}`이 통과(JSON Schema enum은 타입 무관 전체 적용).
127
+ reviewReasoningEffort: { type: ['string', 'null'], enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', null] },
107
128
  designDocs: {
108
129
  type: 'object',
109
130
  additionalProperties: false,
@@ -184,6 +205,10 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
184
205
  packageManager: raw.packageManager ?? DEFAULTS.packageManager,
185
206
  granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
186
207
  designDocs: { ...DEFAULTS.designDocs, ...(raw.designDocs ?? {}) },
208
+ // REQ-2026-013 P1: nullable — 명시적 null 보존을 위해 `!== undefined`(`??` 금지: null이 기본값으로 복귀해 탈출구가 깨짐).
209
+ reviewModel: raw.reviewModel !== undefined ? raw.reviewModel : DEFAULTS.reviewModel,
210
+ reviewReasoningEffort:
211
+ raw.reviewReasoningEffort !== undefined ? raw.reviewReasoningEffort : DEFAULTS.reviewReasoningEffort,
187
212
  }
188
213
 
189
214
  // repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `git status --porcelain=v1 -z` 파서 (REQ-2026-012 phase-1a · 설계 D11·D11-1).
3
+ *
4
+ * 목적: porcelain 출력을 해석하는 **단일 지점**. 이전에는 세 곳이 각자 파싱했고 둘은 경로를 망가뜨렸다
5
+ * - `bin/init.ts`의 `parsePorcelainLine`(rename에서 dest만) — 삭제 예정(phase-1b)
6
+ * - `req-doctor.ts`의 `statusPaths`(인용 해제 없음 + 역슬래시를 `/`로 치환)
7
+ * - `review-codex.ts`의 `findUnstagedOrUntracked` 인라인 파싱(동일 결함)
8
+ *
9
+ * ⚠️ **왜 `-z`인가.** 기본 porcelain은 `"`·`\`·제어문자·(quotePath=true면)비-ASCII가 든 경로를 **C-인용**한다.
10
+ * `core.quotePath=false`는 비-ASCII 인용만 끄므로 나머지는 여전히 인용된다. 인용된 경로를 디코드하면
11
+ * "원문 보존"과 "디코드된 경로 반환"을 동시에 만족할 수 없고(설계 D11), 경로에 ` -> `가 들어가면
12
+ * rename delimiter 분할도 깨진다. `-z`는 **인용을 아예 하지 않으므로** 두 문제가 함께 사라진다.
13
+ *
14
+ * ⚠️ **rename/copy 필드 순서가 `->` 형식과 반대다.**
15
+ * `--porcelain` : `R old -> new`
16
+ * `--porcelain -z` : `R new\0old\0` ← NEW가 먼저
17
+ *
18
+ * ⚠️ **`R`/`C`는 index(X)와 worktree(Y) 양쪽에 온다**(설계 D11-1, `git-status(1)`의 `[ D] R`·`[ D] C`).
19
+ * 실측: `mv a c && git add -N c` → ` R c.txt\0a.txt\0` (X=' ', Y='R').
20
+ * X만 검사하면 OLD 경로가 **독립 레코드로 새어 나가고** `origPath`가 소실된다. 그러면
21
+ * `findUnstagedOrUntracked`/D13이 rename의 src·dest를 둘 다 검사해 막던
22
+ * "비허용 경로 → 허용 경로 rename으로 `responses/` 주입·코드 삭제 우회"(A2-P2-1)가 뚫린다. 보안 회귀다.
23
+ *
24
+ * ⚠️ **경로를 정규화하지 않는다.** git은 언제나 `/`를 구분자로 내므로 역슬래시는 **파일명의 일부**다.
25
+ * 옛 코드의 `.replace(/\\/g, '/')`는 `a\b.txt`를 `a/b.txt`로 뭉갰다 — 그 버그를 여기서 고친다.
26
+ *
27
+ * fail-closed: 형식이 어긋나거나 rename 레코드의 `origPath`가 없으면 throw한다. `undefined`를 흘리지 않는다.
28
+ */
29
+
30
+ /** porcelain v1 레코드 하나. `-z`이므로 `path`는 인용되지 않은 원문이다. */
31
+ export interface StatusEntry {
32
+ /** X — index(스테이지) 상태 문자. 변경 없음은 `' '`. */
33
+ index: string
34
+ /** Y — worktree 상태 문자. 변경 없음은 `' '`. untracked는 X=Y=`'?'`. */
35
+ worktree: string
36
+ /** `-z`가 먼저 내는 경로. rename/copy면 **목적지(NEW)**, 그 외는 유일한 경로. */
37
+ path: string
38
+ /** rename/copy일 때만 존재하는 **원본(OLD)** 경로. */
39
+ origPath?: string
40
+ }
41
+
42
+ /** `R`(rename)·`C`(copy)는 index·worktree 어느 열에 와도 추가 경로 필드를 소비한다(설계 D11-1). */
43
+ export function isRenameOrCopy(index: string, worktree: string): boolean {
44
+ return index === 'R' || index === 'C' || worktree === 'R' || worktree === 'C'
45
+ }
46
+
47
+ /**
48
+ * `git status --porcelain=v1 -z --untracked-files=all`의 **원문**을 엔트리 배열로 분해한다.
49
+ *
50
+ * 레코드 형식은 `XY<space><path>`이고 NUL로 구분된다. rename/copy 레코드는 뒤이어 OLD 경로 필드를 하나 더 갖는다.
51
+ * 후행 NUL이 만드는 마지막 빈 필드만 버린다 — 그 외 위치의 빈 필드는 형식 오류다(fail-closed).
52
+ */
53
+ export function parseStatusZ(raw: string): StatusEntry[] {
54
+ const fields = raw.split('\0')
55
+ // `-z` 출력은 언제나 NUL로 끝나므로 마지막 원소는 빈 문자열이다. 클린 트리(`''`)도 `['']`가 된다.
56
+ if (fields.length > 0 && fields[fields.length - 1] === '') fields.pop()
57
+
58
+ const out: StatusEntry[] = []
59
+ for (let i = 0; i < fields.length; i++) {
60
+ const rec = fields[i]
61
+ if (rec === undefined) throw new Error('porcelain -z: 레코드 인덱스 이탈(내부 오류)')
62
+ // `XY<space>` + 최소 한 글자 경로.
63
+ if (rec.length < 4 || rec[2] !== ' ')
64
+ throw new Error(`porcelain -z: 레코드 형식 오류(XY<space><path> 아님): ${JSON.stringify(rec)}`)
65
+
66
+ const index = rec[0] as string
67
+ const worktree = rec[1] as string
68
+ const path = rec.slice(3) // 정규화하지 않는다 — 역슬래시는 파일명의 일부다.
69
+
70
+ if (isRenameOrCopy(index, worktree)) {
71
+ const origPath = fields[++i]
72
+ if (origPath === undefined || origPath === '')
73
+ throw new Error(`porcelain -z: rename/copy 레코드에 원본 경로가 없다(truncated): ${JSON.stringify(rec)}`)
74
+ out.push({ index, worktree, path, origPath })
75
+ } else {
76
+ out.push({ index, worktree, path })
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * 이 엔트리가 건드리는 **모든** 경로. rename/copy는 `[OLD, NEW]` 순서다.
84
+ *
85
+ * 순서는 옛 `statusPaths`(`[body.slice(0,arrow), body.slice(arrow+4)]` = `[old, new]`)와 같다 —
86
+ * 호출부의 `flatMap(statusPaths)` 시맨틱을 보존한다.
87
+ */
88
+ export function entryPaths(e: StatusEntry): string[] {
89
+ return e.origPath === undefined ? [e.path] : [e.origPath, e.path]
90
+ }
91
+
92
+ /** untracked(`??`) 판정. `-z`에서도 X=Y=`'?'`다. */
93
+ export function isUntracked(e: StatusEntry): boolean {
94
+ return e.index === '?' && e.worktree === '?'
95
+ }
96
+
97
+ /** 사람이 읽는 한 줄. 에러 메시지·진단 출력용(`->` 형식으로 되돌려 익숙한 모양을 유지). */
98
+ export function formatStatusEntry(e: StatusEntry): string {
99
+ const code = `${e.index}${e.worktree}`
100
+ return e.origPath === undefined ? `${code} ${e.path}` : `${code} ${e.origPath} -> ${e.path}`
101
+ }
102
+
103
+ /** 모든 호출부가 공유하는 정본 인자. 다른 형태를 쓰면 파싱 경계가 깨진다(설계 D10). */
104
+ export const STATUS_Z_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all'] as const