commitgate 0.3.1 → 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
@@ -21,12 +21,18 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
21
21
  import { createHash } from 'node:crypto'
22
22
  import { resolve, join, relative } from 'node:path'
23
23
  import { pathToFileURL } from 'node:url'
24
- import { loadConfig, stripBom, DEFAULTS } from '../scripts/req/lib/config'
24
+ import { loadConfig, stripBom, DEFAULTS, DEFAULT_REVIEW_PERSONA_RELPATH } from '../scripts/req/lib/config'
25
25
  import { createGitAdapter, type GitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
26
26
  import {
27
27
  PACKAGE_ROOT,
28
28
  KIT_SOURCE_DIR_REL,
29
29
  KIT_SCHEMA_RELPATHS,
30
+ KIT_COPY_RELPATHS,
31
+ KIT_AGENT_ENTRYPOINTS,
32
+ KIT_GITIGNORE,
33
+ KIT_CLAUDE_TEMPLATE_REL,
34
+ KIT_CLAUDE_DEST_REL,
35
+ KIT_AGENTS_CONTRACT_COPY_REL,
30
36
  REQ_SCRIPTS,
31
37
  REQ_DEV_DEPS,
32
38
  assertGitWorkTree,
@@ -36,7 +42,11 @@ export interface UninstallOptions {
36
42
  dir: string
37
43
  }
38
44
 
39
- /** CommitGate가 복사한 파일(kit 소스 + init이 실제로 복사한 스키마). 바이트 비교로 무결성만 표기. */
45
+ /**
46
+ * CommitGate가 복사한 파일. `path`는 **대상 repo의 경로(dest)**다.
47
+ * 원본(src)은 패키지 안에서 경로가 다를 수 있다(`templates/claude-skill.md` → `.claude/skills/commitgate/SKILL.md`).
48
+ * 바이트 비교로 무결성만 표기.
49
+ */
40
50
  export interface ToolArtifact {
41
51
  path: string // repo-상대
42
52
  present: boolean
@@ -87,6 +97,13 @@ export interface UninstallFacts {
87
97
  * 사용자가 직접 넣은 파일일 수 있으므로 제거 후보에 넣지 않고, 디렉터리 통삭제도 제안하지 않는다.
88
98
  */
89
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
90
107
  }
91
108
 
92
109
  export type UninstallMode = 'not-installed' | 'uncommitted' | 'committed' | 'mixed'
@@ -173,11 +190,13 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
173
190
  // (깨진 config 때문에 제거 안내를 못 받는 건 부당하다 — planner는 쓰기가 없어 강등이 안전).
174
191
  let ticketRoot = DEFAULTS.ticketRoot
175
192
  let schemaPath = DEFAULTS.schemaPath
193
+ let reviewPersonaPath = DEFAULTS.reviewPersonaPath
176
194
  let configError: string | null = null
177
195
  try {
178
196
  const cfg = loadConfig({ root: targetRoot })
179
197
  ticketRoot = cfg.ticketRoot
180
198
  schemaPath = cfg.schemaPath
199
+ reviewPersonaPath = cfg.reviewPersonaPath
181
200
  } catch (e) {
182
201
  configError = (e as Error).message
183
202
  }
@@ -190,25 +209,43 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
190
209
  info.push(
191
210
  `schemaPath=${schemaPath} — 런타임이 읽는 경로이지만 init이 복사한 파일이 아닙니다(제거 후보 아님).`,
192
211
  )
212
+ // reviewPersonaPath도 같은 축 분기(REQ-2026-010). null=의도적 비활성이므로 알릴 것이 없다.
213
+ if (reviewPersonaPath !== null && reviewPersonaPath !== DEFAULT_REVIEW_PERSONA_RELPATH)
214
+ info.push(
215
+ `reviewPersonaPath=${reviewPersonaPath} — 런타임이 읽는 경로이지만 init이 복사한 파일이 아닙니다(제거 후보 아님).`,
216
+ )
193
217
 
194
- // ── tool: kit 소스 + init이 **실제로 복사한** 스키마(항상 리터럴 workflow/ — ticketRoot 무관)
218
+ // ── tool: kit 소스 + init이 **실제로 복사한** 파일들.
219
+ //
220
+ // ⚠️ `src`와 `dest`가 다른 항목이 있다(REQ-2026-010 D8). `KIT_AGENT_ENTRYPOINTS`는 `templates/*` →
221
+ // `.claude/…`·`.cursor/…`로 가고, `AGENTS.commitgate.md`는 `AGENTS.template.md`의 사본이다.
222
+ // "원본을 `join(PACKAGE_ROOT, rel)`로 찾는다"는 가정을 버리고 매핑을 명시한다.
223
+ // 복사 축(KIT_COPY_RELPATHS)을 쓴다. 위 info의 스키마 축(KIT_SCHEMA_RELPATHS)과 의도적으로 다른 상수다.
195
224
  const kitSourceRels = walkFiles(join(PACKAGE_ROOT, KIT_SOURCE_DIR_REL)).map(toRel)
196
- const toolRels = [...kitSourceRels, ...KIT_SCHEMA_RELPATHS]
197
- const tool: ToolArtifact[] = toolRels.map((rel) => {
198
- const dest = join(targetRoot, rel)
199
- const src = join(PACKAGE_ROOT, rel)
225
+ const toolEntries: { destRel: string; srcRel: string }[] = [
226
+ ...kitSourceRels.map((r) => ({ destRel: r, srcRel: r })),
227
+ ...KIT_COPY_RELPATHS.map((r) => ({ destRel: r, srcRel: r })),
228
+ ...KIT_AGENT_ENTRYPOINTS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
229
+ // 마커 없는 AGENTS.md 경로에서만 설치되는 계약 템플릿 사본. 부재가 정상이므로 present=false여도 문제 없다.
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 },
233
+ ]
234
+ const tool: ToolArtifact[] = toolEntries.map(({ destRel, srcRel }) => {
235
+ const dest = join(targetRoot, destRel)
236
+ const src = join(PACKAGE_ROOT, srcRel)
200
237
  if (!existsSync(dest))
201
- return { path: rel, present: false, match: 'absent', tracked: false, introducedBy: null }
238
+ return { path: destRel, present: false, match: 'absent', tracked: false, introducedBy: null }
202
239
  const match: ToolArtifact['match'] =
203
240
  existsSync(src) && sha256(dest) === sha256(src) ? 'identical' : 'differs'
204
- const tracked = isTracked(git, rel)
241
+ const tracked = isTracked(git, destRel)
205
242
  return {
206
- path: rel,
243
+ path: destRel,
207
244
  present: true,
208
245
  match,
209
246
  tracked,
210
247
  // untracked면 이력의 add 커밋은 "지금 이 설치본"의 도입 커밋이 아니다 → 조회조차 하지 않는다(phase R2 P2).
211
- introducedBy: tracked ? (introducingCommit(git, rel)?.sha ?? null) : null,
248
+ introducedBy: tracked ? (introducingCommit(git, destRel)?.sha ?? null) : null,
212
249
  }
213
250
  })
214
251
 
@@ -228,6 +265,21 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
228
265
  })
229
266
  }
230
267
 
268
+ // CLAUDE.md는 AGENTS.md와 같은 계층이다 — init이 **부재 시에만** 만들고 `--force`로도 덮어쓰지 않는다.
269
+ // 사용자 파일일 수 있으므로 자동 제거 대상이 아니다(REQ-2026-010 D7).
270
+ const claudeAbs = join(targetRoot, KIT_CLAUDE_DEST_REL)
271
+ if (existsSync(claudeAbs)) {
272
+ const tpl = join(PACKAGE_ROOT, KIT_CLAUDE_TEMPLATE_REL)
273
+ const same = existsSync(tpl) && sha256(claudeAbs) === sha256(tpl)
274
+ ambiguous.push({
275
+ path: KIT_CLAUDE_DEST_REL,
276
+ present: true,
277
+ note: same
278
+ ? 'init 템플릿과 동일 — 그래도 자동 제거 대상이 아닙니다(에이전트 지침 파일)'
279
+ : '템플릿과 다름 — 사용자/팀이 작성했거나 편집했습니다',
280
+ })
281
+ }
282
+
231
283
  const cfgAbs = join(targetRoot, 'req.config.json')
232
284
  if (existsSync(cfgAbs)) {
233
285
  const parsed = readJsonObject(cfgAbs)
@@ -293,6 +345,9 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
293
345
  const packageJsonDirty = existsSync(pkgAbs) && git.exec(['status', '--porcelain', '--', 'package.json']).length > 0
294
346
  const installed = tool.some((t) => t.present) || ambiguous.some((a) => a.present)
295
347
 
348
+ // Stage B 런타임 제거 안내용(REQ-2026-014 R4) — 선언값 표시만. 값 형태는 검증하지 않는다.
349
+ const commitgateDevDependency = devDeps['commitgate'] ?? null
350
+
296
351
  return {
297
352
  targetRoot,
298
353
  ticketRoot,
@@ -305,6 +360,7 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
305
360
  evidence,
306
361
  info,
307
362
  unknownKitFiles,
363
+ commitgateDevDependency,
308
364
  }
309
365
  }
310
366
 
@@ -419,9 +475,13 @@ export function renderPlan(plan: UninstallPlan): string {
419
475
  L.push(...renderRevertSection(plan))
420
476
  L.push('')
421
477
 
422
- L.push('## 5. 잔여물 경고')
478
+ L.push('## 5. 런타임 패키지 제거 (Stage B)')
479
+ L.push(...renderRuntimeRemovalSection(plan))
480
+ L.push('')
481
+
482
+ L.push('## 6. 잔여물 경고')
423
483
  L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
424
- L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/)가 파일시스템에 남을 수 있습니다.`)
484
+ L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
425
485
  L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
426
486
  L.push('')
427
487
 
@@ -429,6 +489,30 @@ export function renderPlan(plan: UninstallPlan): string {
429
489
  return L.join('\n')
430
490
  }
431
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
+
432
516
  function renderRevertSection(plan: UninstallPlan): string[] {
433
517
  const L: string[] = []
434
518
  const { facts } = plan
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "commitgate",
3
- "version": "0.3.1",
4
- "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
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
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "sol5288",
@@ -32,20 +32,26 @@
32
32
  "req:new": "tsx scripts/req/req-new.ts",
33
33
  "req:review-codex": "tsx scripts/req/review-codex.ts",
34
34
  "req:doctor": "tsx scripts/req/req-doctor.ts",
35
+ "req:next": "tsx scripts/req/req-next.ts",
35
36
  "req:commit": "tsx scripts/req/req-commit.ts",
36
37
  "test": "vitest run",
37
38
  "typecheck": "tsc --noEmit",
38
- "smoke": "node scripts/smoke.mjs"
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "verify:overrides": "node scripts/verify-review-overrides.mjs"
39
41
  },
40
42
  "files": [
41
43
  "scripts/req",
44
+ "scripts/verify-review-overrides.mjs",
42
45
  "workflow/machine.schema.json",
43
46
  "workflow/req.config.schema.json",
47
+ "workflow/review-persona.md",
44
48
  "bin",
49
+ "templates",
45
50
  "AGENTS.template.md",
46
51
  "req.config.json.sample",
47
52
  "README.md",
48
- "README.en.md"
53
+ "README.en.md",
54
+ "CHANGELOG.md"
49
55
  ],
50
56
  "engines": {
51
57
  "node": ">=18.17"
@@ -1,13 +1,16 @@
1
- {
2
- "ticketRoot": "workflow",
3
- "schemaPath": "workflow/machine.schema.json",
4
- "handoffPath": null,
5
- "branchPrefix": "feat/req-",
6
- "packageManager": "npm",
7
- "granularityMaxFiles": 8,
8
- "designDocs": {
9
- "requirement": "00-requirement.md",
10
- "design": "01-design.md",
11
- "plan": "02-plan.md"
12
- }
13
- }
1
+ {
2
+ "ticketRoot": "workflow",
3
+ "schemaPath": "workflow/machine.schema.json",
4
+ "handoffPath": null,
5
+ "reviewPersonaPath": "workflow/review-persona.md",
6
+ "branchPrefix": "feat/req-",
7
+ "packageManager": "npm",
8
+ "granularityMaxFiles": 8,
9
+ "reviewModel": "gpt-5.6-terra",
10
+ "reviewReasoningEffort": "high",
11
+ "designDocs": {
12
+ "requirement": "00-requirement.md",
13
+ "design": "01-design.md",
14
+ "plan": "02-plan.md"
15
+ }
16
+ }
@@ -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') : ''