commitgate 0.4.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/AGENTS.template.md +8 -0
- package/CHANGELOG.md +154 -0
- package/README.en.md +187 -20
- package/README.md +187 -20
- package/bin/commitgate.mjs +15 -6
- package/bin/dispatch.d.mts +6 -0
- package/bin/dispatch.mjs +38 -0
- package/bin/init.ts +965 -84
- package/bin/migrate.ts +244 -0
- package/bin/uninstall.ts +48 -1
- package/package.json +73 -69
- package/req.config.json.sample +3 -0
- package/scripts/req/lib/adapters.ts +56 -8
- package/scripts/req/lib/config.ts +55 -0
- package/scripts/req/lib/porcelain.ts +104 -0
- package/scripts/req/lib/scratch.ts +104 -0
- package/scripts/req/req-commit.ts +21 -7
- package/scripts/req/req-doctor.ts +135 -31
- package/scripts/req/req-new.ts +94 -15
- package/scripts/req/req-next.ts +94 -17
- package/scripts/req/review-codex.ts +851 -94
- package/scripts/verify-review-overrides.mjs +96 -0
- package/skills/ATTRIBUTION.md +85 -0
- package/skills/commitgate-diagnosing-bugs/SKILL.md +149 -0
- package/skills/commitgate-discovery/SKILL.md +93 -0
- package/skills/commitgate-research/SKILL.md +85 -0
- package/skills/commitgate-tdd/SKILL.md +113 -0
- package/templates/CLAUDE.template.md +2 -1
- package/templates/claude-command.md +5 -2
- package/templates/claude-skill.md +4 -1
- package/templates/cursor-rule.mdc +5 -2
- package/templates/workflow.gitignore +8 -0
- package/workflow/machine.schema.json +10 -1
- package/workflow/req.config.schema.json +90 -10
- package/workflow/review-persona.md +56 -1
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,8 @@ import {
|
|
|
29
29
|
KIT_SCHEMA_RELPATHS,
|
|
30
30
|
KIT_COPY_RELPATHS,
|
|
31
31
|
KIT_AGENT_ENTRYPOINTS,
|
|
32
|
+
KIT_GITIGNORE,
|
|
33
|
+
KIT_COMPANION_SKILLS,
|
|
32
34
|
KIT_CLAUDE_TEMPLATE_REL,
|
|
33
35
|
KIT_CLAUDE_DEST_REL,
|
|
34
36
|
KIT_AGENTS_CONTRACT_COPY_REL,
|
|
@@ -96,6 +98,13 @@ export interface UninstallFacts {
|
|
|
96
98
|
* 사용자가 직접 넣은 파일일 수 있으므로 제거 후보에 넣지 않고, 디렉터리 통삭제도 제안하지 않는다.
|
|
97
99
|
*/
|
|
98
100
|
unknownKitFiles: string[]
|
|
101
|
+
/**
|
|
102
|
+
* 대상 `package.json`의 `devDependencies.commitgate` **선언값**(없으면 null) — REQ-2026-014 R4.
|
|
103
|
+
*
|
|
104
|
+
* Stage B 런타임 제거 안내(`npm uninstall -D commitgate`)를 낼지 판정하는 데만 쓴다. **진단용 표시**이고
|
|
105
|
+
* 이 값으로 무엇을 삭제하지 않는다. 값의 형태는 검증하지 않는다(`file:…tgz`·`workspace:*` 전부 정당한 설치 형태).
|
|
106
|
+
*/
|
|
107
|
+
commitgateDevDependency: string | null
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
export type UninstallMode = 'not-installed' | 'uncommitted' | 'committed' | 'mixed'
|
|
@@ -220,6 +229,12 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
220
229
|
...KIT_AGENT_ENTRYPOINTS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
|
|
221
230
|
// 마커 없는 AGENTS.md 경로에서만 설치되는 계약 템플릿 사본. 부재가 정상이므로 present=false여도 문제 없다.
|
|
222
231
|
{ destRel: KIT_AGENTS_CONTRACT_COPY_REL, srcRel: 'AGENTS.template.md' },
|
|
232
|
+
// workflow/.gitignore(REQ-2026-012): src≠dest. identical이면 제거 대상, differs면 review(사용자 편집 보존). 부재 정상.
|
|
233
|
+
{ destRel: KIT_GITIGNORE.dest, srcRel: KIT_GITIGNORE.src },
|
|
234
|
+
// companion skills(REQ-2026-021 D5): src≠dest. `KIT_COMPANION_SKILLS`가 이미 `{src, dest}` shape라 직접 매핑한다.
|
|
235
|
+
// `--no-agent-entrypoints`·미설치면 부재가 **정상**이므로 present=false여도 문제 없다(entrypoint와 같은 성질).
|
|
236
|
+
// ⚠️ 분류는 기존 규칙을 그대로 탄다 — 새 예외를 만들지 않는다. `differs`는 "편집됐거나 **다른 버전**이 설치함"이다.
|
|
237
|
+
...KIT_COMPANION_SKILLS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
|
|
223
238
|
]
|
|
224
239
|
const tool: ToolArtifact[] = toolEntries.map(({ destRel, srcRel }) => {
|
|
225
240
|
const dest = join(targetRoot, destRel)
|
|
@@ -335,6 +350,9 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
335
350
|
const packageJsonDirty = existsSync(pkgAbs) && git.exec(['status', '--porcelain', '--', 'package.json']).length > 0
|
|
336
351
|
const installed = tool.some((t) => t.present) || ambiguous.some((a) => a.present)
|
|
337
352
|
|
|
353
|
+
// Stage B 런타임 제거 안내용(REQ-2026-014 R4) — 선언값 표시만. 값 형태는 검증하지 않는다.
|
|
354
|
+
const commitgateDevDependency = devDeps['commitgate'] ?? null
|
|
355
|
+
|
|
338
356
|
return {
|
|
339
357
|
targetRoot,
|
|
340
358
|
ticketRoot,
|
|
@@ -347,6 +365,7 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
347
365
|
evidence,
|
|
348
366
|
info,
|
|
349
367
|
unknownKitFiles,
|
|
368
|
+
commitgateDevDependency,
|
|
350
369
|
}
|
|
351
370
|
}
|
|
352
371
|
|
|
@@ -461,7 +480,11 @@ export function renderPlan(plan: UninstallPlan): string {
|
|
|
461
480
|
L.push(...renderRevertSection(plan))
|
|
462
481
|
L.push('')
|
|
463
482
|
|
|
464
|
-
L.push('## 5.
|
|
483
|
+
L.push('## 5. 런타임 패키지 제거 (Stage B)')
|
|
484
|
+
L.push(...renderRuntimeRemovalSection(plan))
|
|
485
|
+
L.push('')
|
|
486
|
+
|
|
487
|
+
L.push('## 6. 잔여물 경고')
|
|
465
488
|
L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
|
|
466
489
|
L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
|
|
467
490
|
L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
|
|
@@ -471,6 +494,30 @@ export function renderPlan(plan: UninstallPlan): string {
|
|
|
471
494
|
return L.join('\n')
|
|
472
495
|
}
|
|
473
496
|
|
|
497
|
+
/**
|
|
498
|
+
* **Stage B 런타임 제거 안내**(REQ-2026-014 R4). Stage B에서 실행 코드는 이 저장소가 아니라
|
|
499
|
+
* `node_modules/commitgate`에 있으므로, 런타임 제거는 **package manager의 책임**이다.
|
|
500
|
+
*
|
|
501
|
+
* ⚠️ **문자열로 출력만 한다 — npm을 spawn하지 않는다.** 이 파일의 읽기 전용 불변식이다(`node:fs` 조회 API만 쓰고
|
|
502
|
+
* `node:child_process`를 import하지 않는다). 이 안내를 실제 실행으로 바꾸지 말 것.
|
|
503
|
+
*/
|
|
504
|
+
function renderRuntimeRemovalSection(plan: UninstallPlan): string[] {
|
|
505
|
+
const declared = plan.facts.commitgateDevDependency
|
|
506
|
+
const L: string[] = []
|
|
507
|
+
if (declared !== null) {
|
|
508
|
+
L.push(` package.json 에 devDependencies.commitgate = "${declared}" 가 선언돼 있습니다(Stage B 런타임).`)
|
|
509
|
+
L.push(' 실행 코드(scripts/req/**)는 이 저장소가 아니라 node_modules/commitgate 에 있습니다.')
|
|
510
|
+
L.push(' 런타임 제거는 package manager 가 담당합니다 — 이 명령은 실행하지 않습니다. 직접 실행하세요:')
|
|
511
|
+
L.push(' npm uninstall -D commitgate (pnpm: pnpm remove -D commitgate · yarn: yarn remove commitgate)')
|
|
512
|
+
L.push(' 그러면 devDependencies 선언 · node_modules · lockfile 항목이 함께 정리됩니다.')
|
|
513
|
+
} else {
|
|
514
|
+
L.push(' devDependencies.commitgate 선언이 없습니다 — Stage B 런타임 패키지로 설치된 상태가 아닙니다.')
|
|
515
|
+
L.push(' (Stage A vendored 설치본이거나 `npx commitgate` 일회 실행인 경우입니다 — 아래 npx 캐시 항목 참고.)')
|
|
516
|
+
}
|
|
517
|
+
L.push(' package.json 의 req:* 스크립트는 **수동 정리 후보**입니다 — 이 명령은 고치지 않습니다(2번 항목 참조).')
|
|
518
|
+
return L
|
|
519
|
+
}
|
|
520
|
+
|
|
474
521
|
function renderRevertSection(plan: UninstallPlan): string[] {
|
|
475
522
|
const L: string[] = []
|
|
476
523
|
const { facts } = plan
|
package/package.json
CHANGED
|
@@ -1,69 +1,73 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "commitgate",
|
|
3
|
-
"version": "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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"workflow/
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
}
|
package/req.config.json.sample
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
"branchPrefix": "feat/req-",
|
|
7
7
|
"packageManager": "npm",
|
|
8
8
|
"granularityMaxFiles": 8,
|
|
9
|
+
"reviewModel": "gpt-5.6-terra",
|
|
10
|
+
"reviewReasoningEffort": "high",
|
|
11
|
+
"reviewBudget": { "autoBudget": 5, "hardCap": 8 },
|
|
9
12
|
"designDocs": {
|
|
10
13
|
"requirement": "00-requirement.md",
|
|
11
14
|
"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
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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') : ''
|