commitgate 0.3.0 → 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.0",
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
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * req 워크플로 config 모듈 (REQ-2026-017 Phase 1, portability kit).
2
+ * req 워크플로 config 모듈 (portability kit).
3
3
  *
4
- * 목적: 경로·이름·패키지매니저를 `req.config.json`으로 외부화하되, **파일 부재 시 현재 동작 100% 유지**(DEFAULTS=현재 하드코딩 값).
5
- * Phase는 **모듈만** 제공 4 스크립트 배선·`--root` CLI는 Phase 2, 어댑터는 Phase 3.
4
+ * 목적: 경로·이름·패키지매니저를 `req.config.json`으로 외부화한다. 파일이 없으면 `DEFAULTS`로 해소된다.
5
+ * ⚠️ `DEFAULTS`는 **모든 프로젝트에 유효한 중립 기본값**만 담는다(REQ-2026-009). 프로젝트 고유 값은 config가 흡수한다.
6
6
  *
7
7
  * 안전(fail-closed): config가 게이트를 무력화하거나 경로를 탈출하지 못하도록 AJV 스키마 + 해상도 confinement로 강제.
8
8
  */
@@ -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,13 +49,40 @@ export interface ResolvedConfig {
46
49
  workflowDirAbs: string
47
50
  schemaPathAbs: string
48
51
  handoffPathAbs: string | null
52
+ reviewPersonaPathAbs: string | null
49
53
  }
50
54
 
51
- /** ⚠️ 현재 하드코딩 값 — 변경 시 behavior-preserving(수용기준 #1) 깨짐. */
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
+
68
+ /**
69
+ * 코어 기본값. `req.config.json` 부재 시 이 값으로 해소된다.
70
+ *
71
+ * ⚠️ 여기 있는 값은 **모든 대상 프로젝트에 유효한 중립 기본값**이어야 한다.
72
+ * 특정 프로젝트에만 의미 있는 값(경로·문서 위치 등)은 코어가 아니라 `req.config.json`이 흡수한다.
73
+ * `handoffPath`가 그 예다 — 코어 기본은 **비활성(null)**이고, 쓰려면 config에 명시하거나 `--handoff <path>`로 준다.
74
+ * (REQ-2026-009: 이전 기본값은 특정 사설 프로젝트의 문서 경로였다.)
75
+ *
76
+ * `handoffPath`의 `as string | null`은 의도적이다. 없으면 TS가 리터럴 `null`로 좁혀
77
+ * `DEFAULTS`를 직접 import하는 소비자의 `string | null` 계약이 깨진다.
78
+ */
52
79
  export const DEFAULTS = {
53
80
  ticketRoot: 'workflow',
54
81
  schemaPath: 'workflow/machine.schema.json',
55
- handoffPath: '../palm-kiosk/docs/evaluation/project-memory/ai-handoff.md',
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,
56
86
  branchPrefix: 'feat/req-',
57
87
  packageManager: 'pnpm' as PackageManager,
58
88
  granularityMaxFiles: 8,
@@ -69,6 +99,8 @@ export const CONFIG_SCHEMA = {
69
99
  ticketRoot: { type: 'string', minLength: 1 },
70
100
  schemaPath: { type: 'string', minLength: 1 },
71
101
  handoffPath: { type: ['string', 'null'] },
102
+ // null = 의도적 비활성. 문자열이면 minLength 1(빈 문자열은 "비활성"의 애매한 표현 → 거부, null을 쓰게 한다).
103
+ reviewPersonaPath: { type: ['string', 'null'], minLength: 1 },
72
104
  branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
73
105
  packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
74
106
  granularityMaxFiles: { type: 'integer', minimum: 1 },
@@ -146,17 +178,25 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
146
178
  ticketRoot: raw.ticketRoot ?? DEFAULTS.ticketRoot,
147
179
  schemaPath: raw.schemaPath ?? DEFAULTS.schemaPath,
148
180
  handoffPath: raw.handoffPath !== undefined ? raw.handoffPath : DEFAULTS.handoffPath, // null = 명시적 비활성
181
+ reviewPersonaPath:
182
+ raw.reviewPersonaPath !== undefined ? raw.reviewPersonaPath : DEFAULTS.reviewPersonaPath, // null = 명시적 비활성
149
183
  branchPrefix: raw.branchPrefix ?? DEFAULTS.branchPrefix,
150
184
  packageManager: raw.packageManager ?? DEFAULTS.packageManager,
151
185
  granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
152
186
  designDocs: { ...DEFAULTS.designDocs, ...(raw.designDocs ?? {}) },
153
187
  }
154
188
 
155
- // 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).
156
192
  assertRelative(merged.ticketRoot, 'ticketRoot')
157
193
  assertRelative(merged.schemaPath, 'schemaPath')
158
194
  assertUnderRoot(rootAbs, merged.ticketRoot, 'ticketRoot')
159
195
  assertUnderRoot(rootAbs, merged.schemaPath, 'schemaPath')
196
+ if (merged.reviewPersonaPath !== null) {
197
+ assertRelative(merged.reviewPersonaPath, 'reviewPersonaPath')
198
+ assertUnderRoot(rootAbs, merged.reviewPersonaPath, 'reviewPersonaPath')
199
+ }
160
200
 
161
201
  return {
162
202
  root: rootAbs,
@@ -164,6 +204,7 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
164
204
  workflowDirAbs: resolve(rootAbs, merged.ticketRoot),
165
205
  schemaPathAbs: resolve(rootAbs, merged.schemaPath),
166
206
  handoffPathAbs: merged.handoffPath ? resolve(rootAbs, merged.handoffPath) : null,
207
+ reviewPersonaPathAbs: merged.reviewPersonaPath ? resolve(rootAbs, merged.reviewPersonaPath) : null,
167
208
  }
168
209
  }
169
210
 
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * req:commit — AI REQ 워크플로우 Phase B (REQ-2026-016). 승인된 phase를 커밋하는 래퍼.
4
4
  *
5
- * SSOT 설계: ../palm-kiosk/docs/evaluation/ai-req-workflow-design.md / 본 티켓 01-design.md D-016-3·3b·7·8·9.
5
+ * 설계 근거: 본 티켓 01-design.md D-016-3·3b·7·8·9.
6
6
  * 책임(전체): req:doctor 통과 게이트 → HIGH 사람확인 게이트 → source 커밋(승인 코드만) →
7
7
  * commit_allowed 소비 → evidence-finalize(approvals.jsonl 매니페스트 append + responses chore 커밋) → 2-커밋.
8
8
  * 복구/finalize 모드(pending_evidence_for)·design-finalize 포함.
@@ -2,7 +2,6 @@
2
2
  /**
3
3
  * req:doctor — AI REQ 워크플로우 1차 (단계 4B): 일관성 점검(fail-closed).
4
4
  *
5
- * SSOT: palm-kiosk/docs/evaluation/ai-req-workflow-design.md §8.3.
6
5
  * 1차 최소셋(registry 비의존): D2·D3·D5·D6·D9·D10·D11 + D13(design 선행·freshness)·D15(NEEDS_FIX actionable). (D1/D7/D7b·D4a 등 registry/merge 의존은 2차)
7
6
  * FAIL 1건 이상 → exit 1, 자동 보정 금지(P9). review-codex 헬퍼 재사용.
8
7
  *
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * req:new — AI REQ 워크플로우 1차 (단계 4A): REQ 티켓 + feat/req-* 브랜치 생성.
4
4
  *
5
- * SSOT: palm-kiosk/docs/evaluation/ai-req-workflow-design.md §9.1·§9.2·DEC-WF-020(D11).
5
+ * 설계 근거: DEC-WF-020(D11) — REQ는 main에서 feat/req-* 브랜치로 시작한다.
6
6
  * - state.json은 **BOM 없이** 생성(Node, review-codex의 writeState 재사용).
7
7
  * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
8
  * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.