commitgate 0.3.1 → 0.4.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/uninstall.ts CHANGED
@@ -21,12 +21,17 @@ 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_CLAUDE_TEMPLATE_REL,
33
+ KIT_CLAUDE_DEST_REL,
34
+ KIT_AGENTS_CONTRACT_COPY_REL,
30
35
  REQ_SCRIPTS,
31
36
  REQ_DEV_DEPS,
32
37
  assertGitWorkTree,
@@ -36,7 +41,11 @@ export interface UninstallOptions {
36
41
  dir: string
37
42
  }
38
43
 
39
- /** CommitGate가 복사한 파일(kit 소스 + init이 실제로 복사한 스키마). 바이트 비교로 무결성만 표기. */
44
+ /**
45
+ * CommitGate가 복사한 파일. `path`는 **대상 repo의 경로(dest)**다.
46
+ * 원본(src)은 패키지 안에서 경로가 다를 수 있다(`templates/claude-skill.md` → `.claude/skills/commitgate/SKILL.md`).
47
+ * 바이트 비교로 무결성만 표기.
48
+ */
40
49
  export interface ToolArtifact {
41
50
  path: string // repo-상대
42
51
  present: boolean
@@ -173,11 +182,13 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
173
182
  // (깨진 config 때문에 제거 안내를 못 받는 건 부당하다 — planner는 쓰기가 없어 강등이 안전).
174
183
  let ticketRoot = DEFAULTS.ticketRoot
175
184
  let schemaPath = DEFAULTS.schemaPath
185
+ let reviewPersonaPath = DEFAULTS.reviewPersonaPath
176
186
  let configError: string | null = null
177
187
  try {
178
188
  const cfg = loadConfig({ root: targetRoot })
179
189
  ticketRoot = cfg.ticketRoot
180
190
  schemaPath = cfg.schemaPath
191
+ reviewPersonaPath = cfg.reviewPersonaPath
181
192
  } catch (e) {
182
193
  configError = (e as Error).message
183
194
  }
@@ -190,25 +201,41 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
190
201
  info.push(
191
202
  `schemaPath=${schemaPath} — 런타임이 읽는 경로이지만 init이 복사한 파일이 아닙니다(제거 후보 아님).`,
192
203
  )
204
+ // reviewPersonaPath도 같은 축 분기(REQ-2026-010). null=의도적 비활성이므로 알릴 것이 없다.
205
+ if (reviewPersonaPath !== null && reviewPersonaPath !== DEFAULT_REVIEW_PERSONA_RELPATH)
206
+ info.push(
207
+ `reviewPersonaPath=${reviewPersonaPath} — 런타임이 읽는 경로이지만 init이 복사한 파일이 아닙니다(제거 후보 아님).`,
208
+ )
193
209
 
194
- // ── tool: kit 소스 + init이 **실제로 복사한** 스키마(항상 리터럴 workflow/ — ticketRoot 무관)
210
+ // ── tool: kit 소스 + init이 **실제로 복사한** 파일들.
211
+ //
212
+ // ⚠️ `src`와 `dest`가 다른 항목이 있다(REQ-2026-010 D8). `KIT_AGENT_ENTRYPOINTS`는 `templates/*` →
213
+ // `.claude/…`·`.cursor/…`로 가고, `AGENTS.commitgate.md`는 `AGENTS.template.md`의 사본이다.
214
+ // "원본을 `join(PACKAGE_ROOT, rel)`로 찾는다"는 가정을 버리고 매핑을 명시한다.
215
+ // 복사 축(KIT_COPY_RELPATHS)을 쓴다. 위 info의 스키마 축(KIT_SCHEMA_RELPATHS)과 의도적으로 다른 상수다.
195
216
  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)
217
+ const toolEntries: { destRel: string; srcRel: string }[] = [
218
+ ...kitSourceRels.map((r) => ({ destRel: r, srcRel: r })),
219
+ ...KIT_COPY_RELPATHS.map((r) => ({ destRel: r, srcRel: r })),
220
+ ...KIT_AGENT_ENTRYPOINTS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
221
+ // 마커 없는 AGENTS.md 경로에서만 설치되는 계약 템플릿 사본. 부재가 정상이므로 present=false여도 문제 없다.
222
+ { destRel: KIT_AGENTS_CONTRACT_COPY_REL, srcRel: 'AGENTS.template.md' },
223
+ ]
224
+ const tool: ToolArtifact[] = toolEntries.map(({ destRel, srcRel }) => {
225
+ const dest = join(targetRoot, destRel)
226
+ const src = join(PACKAGE_ROOT, srcRel)
200
227
  if (!existsSync(dest))
201
- return { path: rel, present: false, match: 'absent', tracked: false, introducedBy: null }
228
+ return { path: destRel, present: false, match: 'absent', tracked: false, introducedBy: null }
202
229
  const match: ToolArtifact['match'] =
203
230
  existsSync(src) && sha256(dest) === sha256(src) ? 'identical' : 'differs'
204
- const tracked = isTracked(git, rel)
231
+ const tracked = isTracked(git, destRel)
205
232
  return {
206
- path: rel,
233
+ path: destRel,
207
234
  present: true,
208
235
  match,
209
236
  tracked,
210
237
  // untracked면 이력의 add 커밋은 "지금 이 설치본"의 도입 커밋이 아니다 → 조회조차 하지 않는다(phase R2 P2).
211
- introducedBy: tracked ? (introducingCommit(git, rel)?.sha ?? null) : null,
238
+ introducedBy: tracked ? (introducingCommit(git, destRel)?.sha ?? null) : null,
212
239
  }
213
240
  })
214
241
 
@@ -228,6 +255,21 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
228
255
  })
229
256
  }
230
257
 
258
+ // CLAUDE.md는 AGENTS.md와 같은 계층이다 — init이 **부재 시에만** 만들고 `--force`로도 덮어쓰지 않는다.
259
+ // 사용자 파일일 수 있으므로 자동 제거 대상이 아니다(REQ-2026-010 D7).
260
+ const claudeAbs = join(targetRoot, KIT_CLAUDE_DEST_REL)
261
+ if (existsSync(claudeAbs)) {
262
+ const tpl = join(PACKAGE_ROOT, KIT_CLAUDE_TEMPLATE_REL)
263
+ const same = existsSync(tpl) && sha256(claudeAbs) === sha256(tpl)
264
+ ambiguous.push({
265
+ path: KIT_CLAUDE_DEST_REL,
266
+ present: true,
267
+ note: same
268
+ ? 'init 템플릿과 동일 — 그래도 자동 제거 대상이 아닙니다(에이전트 지침 파일)'
269
+ : '템플릿과 다름 — 사용자/팀이 작성했거나 편집했습니다',
270
+ })
271
+ }
272
+
231
273
  const cfgAbs = join(targetRoot, 'req.config.json')
232
274
  if (existsSync(cfgAbs)) {
233
275
  const parsed = readJsonObject(cfgAbs)
@@ -421,7 +463,7 @@ export function renderPlan(plan: UninstallPlan): string {
421
463
 
422
464
  L.push('## 5. 잔여물 경고')
423
465
  L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
424
- L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/)가 파일시스템에 남을 수 있습니다.`)
466
+ L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
425
467
  L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
426
468
  L.push('')
427
469
 
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.4.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,6 +32,7 @@
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",
@@ -41,7 +42,9 @@
41
42
  "scripts/req",
42
43
  "workflow/machine.schema.json",
43
44
  "workflow/req.config.schema.json",
45
+ "workflow/review-persona.md",
44
46
  "bin",
47
+ "templates",
45
48
  "AGENTS.template.md",
46
49
  "req.config.json.sample",
47
50
  "README.md",
@@ -1,13 +1,14 @@
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
+ "designDocs": {
10
+ "requirement": "00-requirement.md",
11
+ "design": "01-design.md",
12
+ "plan": "02-plan.md"
13
+ }
14
+ }
@@ -26,6 +26,8 @@ export interface RawConfig {
26
26
  ticketRoot?: string
27
27
  schemaPath?: string
28
28
  handoffPath?: string | null
29
+ /** null = 의도적 비활성(persona 블록 생략). 미지정 = DEFAULTS(활성). */
30
+ reviewPersonaPath?: string | null
29
31
  branchPrefix?: string
30
32
  packageManager?: PackageManager
31
33
  granularityMaxFiles?: number
@@ -38,6 +40,7 @@ export interface ResolvedConfig {
38
40
  ticketRoot: string
39
41
  schemaPath: string
40
42
  handoffPath: string | null
43
+ reviewPersonaPath: string | null
41
44
  branchPrefix: string
42
45
  packageManager: PackageManager
43
46
  granularityMaxFiles: number
@@ -46,8 +49,22 @@ export interface ResolvedConfig {
46
49
  workflowDirAbs: string
47
50
  schemaPathAbs: string
48
51
  handoffPathAbs: string | null
52
+ reviewPersonaPathAbs: string | null
49
53
  }
50
54
 
55
+ /**
56
+ * Codex 리뷰 프롬프트에 주입되는 **리뷰어 페르소나** 문서의 repo-상대 경로(코어 기본값).
57
+ *
58
+ * ⚠️ 이 상수는 두 축의 SSOT다(REQ-2026-010 D3-1).
59
+ * - **설치 축**: `bin/init.ts`의 `KIT_COPY_RELPATHS`가 이 경로를 대상 repo에 복사한다.
60
+ * - **설정 축**: `DEFAULTS.reviewPersonaPath`가 이 값으로 해소된다(phase-1b에서 도입).
61
+ *
62
+ * 둘이 갈라지면 신규 설치본은 프롬프트 조립 시 이 파일을 찾지 못하고 **모든 리뷰가 fail-closed로 멈춘다.**
63
+ * `tests/unit/init.test.ts`의 "설치 축 SSOT"가 그 드리프트를 회귀로 잡는다.
64
+ * `package.json`의 `files[]`는 또 **다른 축**(npm tarball 적재분)이므로 함께 갱신해야 한다.
65
+ */
66
+ export const DEFAULT_REVIEW_PERSONA_RELPATH = 'workflow/review-persona.md'
67
+
51
68
  /**
52
69
  * 코어 기본값. `req.config.json` 부재 시 이 값으로 해소된다.
53
70
  *
@@ -63,6 +80,9 @@ export const DEFAULTS = {
63
80
  ticketRoot: 'workflow',
64
81
  schemaPath: 'workflow/machine.schema.json',
65
82
  handoffPath: null as string | null,
83
+ // ⚠️ handoffPath와 달리 코어 기본이 **활성**이다. init이 이 경로에 파일을 깔기 때문(KIT_COPY_RELPATHS).
84
+ // 비활성이 필요하면 config에 `null`을 명시한다. `as string | null`은 handoffPath와 같은 이유(직접 import 소비자 계약).
85
+ reviewPersonaPath: DEFAULT_REVIEW_PERSONA_RELPATH as string | null,
66
86
  branchPrefix: 'feat/req-',
67
87
  packageManager: 'pnpm' as PackageManager,
68
88
  granularityMaxFiles: 8,
@@ -79,6 +99,8 @@ export const CONFIG_SCHEMA = {
79
99
  ticketRoot: { type: 'string', minLength: 1 },
80
100
  schemaPath: { type: 'string', minLength: 1 },
81
101
  handoffPath: { type: ['string', 'null'] },
102
+ // null = 의도적 비활성. 문자열이면 minLength 1(빈 문자열은 "비활성"의 애매한 표현 → 거부, null을 쓰게 한다).
103
+ reviewPersonaPath: { type: ['string', 'null'], minLength: 1 },
82
104
  branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
83
105
  packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
84
106
  granularityMaxFiles: { type: 'integer', minimum: 1 },
@@ -156,17 +178,25 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
156
178
  ticketRoot: raw.ticketRoot ?? DEFAULTS.ticketRoot,
157
179
  schemaPath: raw.schemaPath ?? DEFAULTS.schemaPath,
158
180
  handoffPath: raw.handoffPath !== undefined ? raw.handoffPath : DEFAULTS.handoffPath, // null = 명시적 비활성
181
+ reviewPersonaPath:
182
+ raw.reviewPersonaPath !== undefined ? raw.reviewPersonaPath : DEFAULTS.reviewPersonaPath, // null = 명시적 비활성
159
183
  branchPrefix: raw.branchPrefix ?? DEFAULTS.branchPrefix,
160
184
  packageManager: raw.packageManager ?? DEFAULTS.packageManager,
161
185
  granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
162
186
  designDocs: { ...DEFAULTS.designDocs, ...(raw.designDocs ?? {}) },
163
187
  }
164
188
 
165
- // repo-내부 자원(ticketRoot·schemaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable). handoffPath는 읽기 전용 참조라 면제.
189
+ // repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
190
+ // handoffPath만 면제 — 형제 repo의 SSOT 문서를 읽는 **외부 참조**이기 때문.
191
+ // reviewPersonaPath는 패키지가 배포하고 init이 repo 안에 까는 자원이라 schemaPath와 같은 축이다(REQ-2026-010 D2).
166
192
  assertRelative(merged.ticketRoot, 'ticketRoot')
167
193
  assertRelative(merged.schemaPath, 'schemaPath')
168
194
  assertUnderRoot(rootAbs, merged.ticketRoot, 'ticketRoot')
169
195
  assertUnderRoot(rootAbs, merged.schemaPath, 'schemaPath')
196
+ if (merged.reviewPersonaPath !== null) {
197
+ assertRelative(merged.reviewPersonaPath, 'reviewPersonaPath')
198
+ assertUnderRoot(rootAbs, merged.reviewPersonaPath, 'reviewPersonaPath')
199
+ }
170
200
 
171
201
  return {
172
202
  root: rootAbs,
@@ -174,6 +204,7 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
174
204
  workflowDirAbs: resolve(rootAbs, merged.ticketRoot),
175
205
  schemaPathAbs: resolve(rootAbs, merged.schemaPath),
176
206
  handoffPathAbs: merged.handoffPath ? resolve(rootAbs, merged.handoffPath) : null,
207
+ reviewPersonaPathAbs: merged.reviewPersonaPath ? resolve(rootAbs, merged.reviewPersonaPath) : null,
177
208
  }
178
209
  }
179
210