commitgate 0.9.6 → 0.9.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  이 프로젝트는 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
4
4
 
5
+ ## 0.9.8
6
+
7
+ **design 승인 증거가 커밋 이력에 확실히 남습니다** (REQ-2026-048). CommitGate는 `state.json`을 의도적으로 커밋하지 않으므로 저장소의 감사 정본은 `approvals.jsonl` 매니페스트와 커밋된 응답 아카이브뿐인데, 그 정본을 만드는 경로가 비대칭이었습니다 — **phase 증거는 매 `req:commit`이 자동 커밋**(needs-fix 라운드 포함)하는 반면 **design 증거는 수동 `req:commit --finalize-design`에만 의존**했고, 그마저 승인본 1건만 커밋했습니다. 게다가 그 수동 단계는 어떤 도구 출력·문서에도 안내되지 않았고, 커밋된 매니페스트를 확인하는 게이트도 없었습니다(D13은 미커밋 `state.json` 플래그를, D17은 **온디스크** 아카이브를 봅니다). 그 결과 REQ가 "전 phase 커밋 + 병합"에 도달해도 **설계 승인 증거가 커밋 이력에 전혀 남지 않을 수 있었고 아무 게이트도 불평하지 않았습니다** — 소비자 저장소에서 실측된 사고입니다.
8
+
9
+ 이제 성공한 `req:review-codex --kind design --run`이 **승인 아카이브·needs-fix 라운드·매니페스트를 그 자리에서 커밋**합니다. 운영자가 별도 명령을 기억할 필요가 없습니다. `--finalize-design`은 제거하지 않고 **멱등 복구 경로**로 남아 같은 구현을 호출하므로 두 경로의 동작이 갈라질 수 없습니다. 멱등 판정은 온디스크가 아니라 **`HEAD` 기준**입니다 — 매니페스트 기록·stage까지 되고 커밋만 실패한 부분 상태에서 재시도가 영구히 skip되어 증거를 복구하지 못하던 함정을 없앴습니다. 커밋 실패는 **승인 판정이나 종료 코드를 바꾸지 않고**(기록 실패가 게이트 결정을 뒤집으면 계약 위반입니다) 복구 명령을 안내합니다. 커밋은 **pathspec 범위**라 설계 문서를 stage한 채 승인하는 정상 경로에서도 무관한 staged 변경이 섞이지 않고 index에 그대로 남습니다.
10
+
11
+ design 매니페스트 행에 **`archive_inventory`**(각 아카이브의 경로·SHA-256)를 추가해 그 승인에 이르는 **모든 라운드**를 함께 영속화합니다. 목록은 승인 시점 티켓 `responses/` 직계의 design 아카이브 전부를 라운드 오름차순으로 담아 디렉터리 읽기 순서에 비의존이며, 파일명 sweep과 달리 사후 감사에서 **재검증 가능**합니다. 선택 필드라 기존 매니페스트는 그대로 유효합니다.
12
+
13
+ **`req:next`가 완료를 선언하기 직전** `HEAD`의 Git blob에서 매니페스트 design 행·아카이브·SHA를 검증하고, 미완이면 `DONE` 대신 **`BLOCKED`와 복구 명령**을 반환합니다. 판별 marker(`evidence_durability_required`, `req:new`가 스캐폴드에 심음)도 **커밋된 blob**에서 읽어 캐시 소실로 우회되지 않습니다. 🔴 이 검사는 **`req:next`의 완료 판정에서만** fail-closed입니다 — `req:doctor`·일반 `req:commit`에는 넣지 않았습니다(doctor는 `req:commit`의 하드 게이트라 FAIL이면 기존 소비자의 모든 커밋이 벽돌이 됩니다). **0.9.8 이전에 만들어진 티켓은 검사 대상이 아니며 기존 DONE 동작을 유지합니다.**
14
+
15
+ 내부적으로는 매니페스트 모델·검증을 leaf 모듈 `scripts/req/lib/evidence.ts`로 추출해 `review-codex`↔`req-commit` 런타임 순환 없이 두 경로가 같은 구현을 공유하게 했고(그 순환이 흡수를 막던 구조적 원인입니다), 그 leaf 불변식을 테스트로 고정했습니다.
16
+
17
+ **DONE 게이트가 실제로 검증하는 것**(REQ-2026-049에서 fail-closed로 보강): `HEAD`의 Git blob만 보고 ① 커밋된 `state.json`이 해석 가능한지(부재·파손·`phases` 비배열이면 BLOCKED) ② 커밋된 `approvals.jsonl` 전체가 매니페스트 검증(스키마·경로 confinement·`-approved.json` 파일명·SHA 형식·예상 외 필드·중복/주입)을 통과하는지 ③ design 행의 `response_sha256`이 **HEAD blob의 SHA와 일치**하는지(존재 확인이 아니라 대조) ④ `archive_inventory`가 **비어 있지 않고** 승인 아카이브를 정확한 SHA로 포함하는지 ⑤ 인벤토리가 **HEAD에 있는 그 티켓 design 아카이브 전체 집합과 정확히 일치**하는지(빠짐·잉여 모두 거부) ⑥ 각 인벤토리 항목의 SHA가 HEAD blob과 일치하는지를 확인합니다. 초기 구현은 존재만 확인하고 빈 인벤토리를 통과시켜, 손상된 커밋 매니페스트가 완료 판정을 통과할 수 있었습니다.
18
+
19
+ > 한 가지 예외가 있습니다: **phase 행의 `phase_id` 멤버십은 이 게이트가 검사하지 않습니다.** `state.json`은 설계상 스캐폴드 이후 재커밋되지 않아 `HEAD`의 `phases`가 항상 비어 있기 때문입니다. 그 바인딩은 커밋 시점에 `req:commit`의 evidence preflight가 이미 강제합니다.
20
+
21
+ 테스트 환경은 **global/system git config와 `EMAIL` 등 환경 유래 identity를 차단**합니다. 그러지 않으면 저장소-local identity를 빠뜨린 fixture가 개발자 머신의 전역 설정에 가려 **CI에서만 실패**합니다(실제로 그렇게 됐습니다). 전체 테스트 1306 → 1363.
22
+
23
+ ## 0.9.7
24
+
25
+ **소비자 저장소에서 review-call 측정 로그가 커밋을 막던 P0 수정 + 기존 설치본 백필** (REQ-2026-047). `req:review-codex`가 소비 저장소 루트에 남기는 측정 로그(`workflow/.review-calls.jsonl`)의 무시 규칙이 **배포 템플릿 `templates/workflow.gitignore`에 누락**돼 있었습니다(개발 저장소 자신의 루트 `.gitignore`에만 있었고, npm은 `.gitignore` 이름을 tarball에서 제외하므로 소비자에게 전달되지 않습니다). 그 결과 `commitgate init`한 저장소에서 리뷰를 한 번이라도 돌리면 로그가 untracked로 남아 **`req:doctor` D10이 FAIL하고 `req:commit`이 모든 커밋을 차단**했습니다. 템플릿에 앵커형 `/.review-calls.jsonl`을 추가해 **신규 설치는 즉시 해소**되고, 회귀는 문자열 비교가 아니라 **packed tarball → 실제 `init` → `git check-ignore -v`(매칭 출처까지 단언)** 로 `scripts/smoke.mjs`에 고정했습니다.
26
+
27
+ `workflow/.gitignore`는 seed-once(부재 시에만 생성, `--force`로도 미덮음)라 템플릿 수정만으로는 기존 설치본이 구제되지 않으므로, 명시적 opt-in **`commitgate sync --gitignore [--apply]`** 를 추가했습니다 — 누락된 kit 규칙 **행만 말미에 추가**하고 기존 행은 변경·삭제·재정렬하지 않으며, 파일이 없으면 템플릿 전체로 생성합니다. 존재 판정은 **Git ignore 의미론을 보존**해 후행 공백·CR만 무시하고 **앞 공백은 패턴의 일부로 취급**합니다(` /.review-calls.jsonl`처럼 실제로는 무시되지 않는 행을 "이미 있음"으로 오판해 백필을 건너뛰지 않도록). **`sync` 기본 동작은 불변**이라 `--gitignore` 없이는 이 파일을 전혀 건드리지 않습니다.
28
+
29
+ 진단으로 **`req:doctor` D22**를 추가했습니다 — repo-root 런타임 스크래치가 ignore도 tracked도 아니면 "다음 review 뒤 D10이 커밋을 막는다"를 알리고 백필 명령을 안내합니다. **WARN 상한이며 절대 FAIL이 아닙니다**(doctor는 `req:commit`의 하드 게이트라 FAIL이면 소비자 커밋이 벽돌이 됩니다). D10의 스크래치 의미론(`reviewScratchPaths`)은 **의도적으로 무변경**입니다 — 로그를 스크래치 허용목록에 넣으면 배포 ignore 누락 자체를 D10이 숨기게 됩니다. 런타임 생성 파일 인벤토리 표와 이미 커밋해 tracked가 된 경우의 복구(`git rm --cached`) 절차는 [문제 해결](https://github.com/sol5288/commitgate/blob/main/docs/troubleshooting.md)에 정리했습니다.
30
+
5
31
  ## 0.9.6
6
32
 
7
33
  **Claude Code용 품질 오버레이 companion skill `commitgate-quality` 추가** (REQ-2026-044). 기존 4종에 이어 5번째 companion skill을 같은 안전한 설치 경로(seed-once·`--force` 미덮음·confinement·`--no-agent-entrypoints` opt-out·uninstall)로 번들·설치합니다. 이 스킬은 Superpowers 방법론의 장점(요구 정제·설계/계획 품질·Test-First·증거 기반 검증)만 **협조적 지침**으로 흡수하며, Superpowers 플러그인·런타임은 설치·실행·의존하지 않습니다. 정본(SSOT) 비복제·설계 품질·계획 품질은 자체 소유하고, Test-First·버그 진단·요구 정제는 형제 스킬(`commitgate-tdd`·`commitgate-diagnosing-bugs`·`commitgate-discovery`)을 가리켜 내부 중복을 피합니다. 새 설치의 `CLAUDE.md`에 발견 포인터 1줄을 추가하되 계약 정본(`AGENTS.md`)은 불변입니다. **강제는 CommitGate 실행 게이트가 담당하며 이 스킬은 방법일 뿐**입니다 — `req:next`의 행동 계산, 리뷰·승인 판정, `state.json`/`responses/`, 커밋 권한을 침범하지 않습니다.
package/bin/init.ts CHANGED
@@ -564,7 +564,7 @@ export function sha256File(abs: string): string {
564
564
  }
565
565
 
566
566
  /** kit `workflow/.gitignore` 템플릿의 규칙 라인(주석·빈 줄 제외). differs WARN에서 사용자가 병합할 실제 규칙을 보여 준다. */
567
- function kitGitignoreRules(): string[] {
567
+ export function kitGitignoreRules(): string[] {
568
568
  return readFileSync(join(PACKAGE_ROOT, KIT_GITIGNORE.src), 'utf8')
569
569
  .split('\n')
570
570
  .map((l) => l.trim())
package/bin/sync.ts CHANGED
@@ -15,7 +15,13 @@
15
15
  * fail-closed로 리뷰를 멈추므로 복원이 순이득). 내용이 다르면 사용자 편집일 수 있어 **덮지 않고 report-only**
16
16
  * (manifest 없이 stale-kit↔사용자편집 구별 불가 → 편집 보존 방향; design-r02 P1). custom 경로·null은 unmanaged.
17
17
  *
18
- * 하는 일: companion skills·workflow/.gitignore·package.json·req:*·req.config.json·에이전트 진입점 미접촉.
18
+ * - **`workflow/.gitignore`(`--gitignore` opt-in, REQ-2026-047)**: 덮어쓰기 0건. kit 템플릿 규칙 중 **없는 행만**
19
+ * 말미에 append한다(기존 행 미변경·미재정렬·미삭제). 파일 부재면 템플릿 전체로 생성(= 전 규칙 누락의 경계 사례).
20
+ * 0.9.6 이하 설치본에는 review-call 로그 규칙이 없어 첫 리뷰 뒤 D10이 커밋을 막는다 — 그 백필 경로다.
21
+ * 존재 판정은 Git 의미론을 보존한다(앞 공백은 패턴의 일부 — `normalizeIgnoreLine`).
22
+ *
23
+ * 안 하는 일: companion skills·package.json·req:*·req.config.json·에이전트 진입점 미접촉.
24
+ * `workflow/.gitignore`도 `--gitignore` 없이는 완전 미접촉(**기본 동작 불변**).
19
25
  * 캐럿 범위(`^0.x`)는 소비자 package.json에서 PM이 강제하므로 코드로 못 고친다 — 문서(업그레이드 절)가 안내.
20
26
  *
21
27
  * ⚠️ **confinement는 재구현하지 않는다.** 모든 쓰기가 `statWritableDest`(bin/init.ts) 단일 경로를 탄다 —
@@ -27,11 +33,19 @@
27
33
  * ⚠️ **동기 구현이어야 한다.** launcher(bin/commitgate.mjs)가 `mod.runCli(rest)`를 await 없이 호출한다 —
28
34
  * async면 promise가 버려져 exit code가 소실된다(migrate.ts:18-19와 동일).
29
35
  */
30
- import { existsSync, copyFileSync, mkdirSync, realpathSync } from 'node:fs'
36
+ import { existsSync, copyFileSync, mkdirSync, realpathSync, readFileSync, writeFileSync } from 'node:fs'
31
37
  import { resolve, join, dirname, relative } from 'node:path'
32
38
  import { pathToFileURL } from 'node:url'
33
39
  import { loadConfig, DEFAULT_REVIEW_PERSONA_RELPATH, type ResolvedConfig } from '../scripts/req/lib/config'
34
- import { PACKAGE_ROOT, KIT_SCHEMA_RELPATHS, statWritableDest, sha256File, assertGitWorkTree } from './init'
40
+ import {
41
+ PACKAGE_ROOT,
42
+ KIT_SCHEMA_RELPATHS,
43
+ KIT_GITIGNORE,
44
+ kitGitignoreRules,
45
+ statWritableDest,
46
+ sha256File,
47
+ assertGitWorkTree,
48
+ } from './init'
35
49
 
36
50
  export interface SyncOptions {
37
51
  dir: string
@@ -39,6 +53,11 @@ export interface SyncOptions {
39
53
  apply: boolean
40
54
  /** 페르소나 처리 opt-in. 없으면 페르소나는 완전 미접촉. */
41
55
  persona: boolean
56
+ /**
57
+ * `workflow/.gitignore` 규칙 보강 opt-in(REQ-2026-047). 없으면 완전 미접촉 — **기본 동작 불변**.
58
+ * 생략 가능: 기존 호출부(3필드)를 깨지 않는다.
59
+ */
60
+ gitignore?: boolean
42
61
  }
43
62
 
44
63
  /**
@@ -49,20 +68,76 @@ export interface SyncOptions {
49
68
  * - `preserved-differs`: 기본 경로 페르소나가 shipped와 다름 → **미접촉**(사용자 편집 보존, report-only).
50
69
  * - `unmanaged-custom` / `unmanaged-null`: 페르소나 경로가 custom/null → 미접촉.
51
70
  */
52
- export type AssetStatus = 'new' | 'in-sync' | 'stale' | 'preserved-differs' | 'unmanaged-custom' | 'unmanaged-null'
71
+ export type AssetStatus =
72
+ | 'new'
73
+ | 'in-sync'
74
+ | 'stale'
75
+ | 'preserved-differs'
76
+ | 'unmanaged-custom'
77
+ | 'unmanaged-null'
78
+ /** gitignore 축: 파일은 있으나 kit 규칙 일부가 없음 → **누락 행만** 말미에 append(REQ-2026-047). */
79
+ | 'rules-missing'
53
80
 
54
81
  export interface AssetPlan {
55
82
  rel: string // 대상-상대 경로(표시용)
56
- axis: 'schema' | 'persona'
83
+ axis: 'schema' | 'persona' | 'gitignore'
57
84
  status: AssetStatus
58
85
  note?: string
59
86
  }
60
87
 
88
+ /** `workflow/.gitignore`에 덧붙일 누락 kit 규칙(REQ-2026-047). 기존 행은 건드리지 않는다. */
89
+ export interface GitignoreAppend {
90
+ destRel: string
91
+ /** kit 원문 그대로의 규칙 행들(순서 보존). */
92
+ missing: string[]
93
+ }
94
+
61
95
  export interface SyncPlan {
62
96
  targetRoot: string
63
97
  assets: AssetPlan[]
64
- /** apply 시 실제 복사할 항목(스키마 new/stale + 페르소나 부재복원만). */
98
+ /** apply 시 실제 복사할 항목(스키마 new/stale + 페르소나 부재복원 + gitignore 파일 부재 시 템플릿 전체). */
65
99
  writes: { srcAbs: string; destRel: string }[]
100
+ /** apply 시 **행 단위 append**할 항목(gitignore 축 전용). 복사가 아니라 추가라 writes와 분리한다. */
101
+ appends: GitignoreAppend[]
102
+ }
103
+
104
+ /**
105
+ * gitignore 행 정규화(존재 판정용) — 🔴 **Git ignore 의미론을 보존한다**(design r01 P1).
106
+ *
107
+ * gitignore(5): **후행 공백은 무시되지만(백슬래시로 이스케이프한 경우 제외) 앞 공백은 패턴의 일부**다.
108
+ * 따라서 후행 `\r`과 후행 공백만 제거하고 **앞 공백은 보존**한다.
109
+ *
110
+ * 결과적으로 ` /.review-calls.jsonl`(앞 공백)은 kit 규칙과 **다른 패턴**으로 판정되어 누락 취급되고,
111
+ * 정확한 규칙이 append된다. 반대로 트림 비교를 쓰면 "이미 있다"고 오판해 append를 건너뛰지만 Git은
112
+ * 그 파일을 무시하지 않아 다음 review 뒤 **D10 FAIL(P0)이 재발**한다.
113
+ *
114
+ * 방향은 **fail-safe**: 과(過)append는 무해(정확한 규칙이 추가되어 ignore가 성립)하고, 미(未)append는 P0 재발이다.
115
+ */
116
+ export function normalizeIgnoreLine(line: string): string {
117
+ // 후행 공백 제거 — 단 `\ `처럼 백슬래시로 이스케이프된 공백은 패턴의 일부라 보존한다.
118
+ return line.replace(/\r+$/, '').replace(/(?<!\\)[ \t]+$/, '')
119
+ }
120
+
121
+ /**
122
+ * 대상 `workflow/.gitignore` 본문에 없는 kit 규칙 행 목록(순수 — 테스트가 직접 구동한다).
123
+ *
124
+ * 비교는 `normalizeIgnoreLine`으로 하되 **kit 규칙 원문을 그대로 반환**한다(append되는 것은 정확한 kit 형태).
125
+ * 주석·빈 줄은 kit 규칙 목록에 없으므로(`kitGitignoreRules`) 자연히 제외된다.
126
+ */
127
+ export function missingKitIgnoreRules(existingContent: string, kitRules: readonly string[]): string[] {
128
+ const present = new Set(existingContent.split('\n').map(normalizeIgnoreLine))
129
+ return kitRules.filter((r) => !present.has(normalizeIgnoreLine(r)))
130
+ }
131
+
132
+ /**
133
+ * 누락 규칙을 본문 말미에 덧붙인 새 본문(순수). 기존 행은 **한 글자도 바꾸지 않는다**.
134
+ * 원본의 개행 관례(CRLF/LF)를 따르고, 마지막 줄에 개행이 없으면 먼저 채운다.
135
+ */
136
+ export function appendIgnoreRules(existingContent: string, missing: readonly string[]): string {
137
+ if (missing.length === 0) return existingContent
138
+ const eol = existingContent.includes('\r\n') ? '\r\n' : '\n'
139
+ const needsLeadingEol = existingContent.length > 0 && !/\r?\n$/.test(existingContent)
140
+ return existingContent + (needsLeadingEol ? eol : '') + missing.join(eol) + eol
66
141
  }
67
142
 
68
143
  /** targetRoot·PACKAGE_ROOT 동일성 판정용 정규화(Windows 8.3·case·symlink 차이 흡수 — init.assertGitWorkTree와 동일 기법). */
@@ -78,9 +153,10 @@ function canonical(p: string): string {
78
153
  * 재동기화 계획 수립(순수 판정 — 쓰기 없음). statWritableDest로 confinement + leaf를 판정하고 sha로 멱등 skip.
79
154
  * `--persona` 없으면 페르소나는 계획에 넣지 않는다(완전 미접촉).
80
155
  */
81
- export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boolean): SyncPlan {
156
+ export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boolean, gitignore = false): SyncPlan {
82
157
  const assets: AssetPlan[] = []
83
158
  const writes: { srcAbs: string; destRel: string }[] = []
159
+ const appends: GitignoreAppend[] = []
84
160
 
85
161
  // ── 스키마 축(무조건 재동기화 — 계약, --force 축) ──
86
162
  for (const rel of KIT_SCHEMA_RELPATHS) {
@@ -128,11 +204,37 @@ export function planSync(targetRoot: string, cfg: ResolvedConfig, persona: boole
128
204
  }
129
205
  }
130
206
 
131
- return { targetRoot, assets, writes }
207
+ // ── workflow/.gitignore 규칙 보강(--gitignore opt-in — REQ-2026-047) ──
208
+ // 🔴 이 파일은 git 관례상 **사용자 소유**다(init D12: 부재 시에만 생성, --force로도 미덮어씀).
209
+ // 따라서 **additive append 전용** — 기존 행을 수정·삭제·재정렬하지 않는다. 덮어쓰기는 절대 없다.
210
+ if (gitignore) {
211
+ const rel = KIT_GITIGNORE.dest
212
+ const st = statWritableDest(targetRoot, rel) // confinement + leaf(symlink escape 거부)
213
+ if (st === null) {
214
+ // 파일 부재 = "모든 kit 규칙이 누락된 상태"의 경계 사례 → 템플릿 전체로 생성(init의 seed와 동일 산출물).
215
+ assets.push({ rel, axis: 'gitignore', status: 'new', note: '부재 — kit 템플릿 전체로 생성' })
216
+ writes.push({ srcAbs: join(PACKAGE_ROOT, KIT_GITIGNORE.src), destRel: rel })
217
+ } else {
218
+ const missing = missingKitIgnoreRules(readFileSync(join(targetRoot, rel), 'utf8'), kitGitignoreRules())
219
+ if (missing.length === 0) {
220
+ assets.push({ rel, axis: 'gitignore', status: 'in-sync', note: 'kit 규칙 전부 존재' })
221
+ } else {
222
+ assets.push({
223
+ rel,
224
+ axis: 'gitignore',
225
+ status: 'rules-missing',
226
+ note: `누락 ${missing.length}행 → 말미에 추가(기존 행 미변경): ${missing.join(' , ')}`,
227
+ })
228
+ appends.push({ destRel: rel, missing })
229
+ }
230
+ }
231
+ }
232
+
233
+ return { targetRoot, assets, writes, appends }
132
234
  }
133
235
 
134
236
  /** 계획을 사람이 읽는 줄 배열로. shell 연산자 미사용(Windows PowerShell/cmd 호환 — DEC-011-8). */
135
- export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean): string[] {
237
+ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean, gitignore = false): string[] {
136
238
  const L: string[] = []
137
239
  const GLYPH: Record<AssetStatus, string> = {
138
240
  new: '+',
@@ -141,6 +243,7 @@ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean): st
141
243
  'preserved-differs': '!',
142
244
  'unmanaged-custom': '·',
143
245
  'unmanaged-null': '·',
246
+ 'rules-missing': '+',
144
247
  }
145
248
  L.push('')
146
249
  L.push(`[commitgate sync] vendored 계약 재동기화 ${apply ? '(--apply: 파일을 씁니다)' : '계획 (dry-run — 아무것도 쓰지 않습니다)'}`)
@@ -154,17 +257,24 @@ export function renderPlan(plan: SyncPlan, apply: boolean, persona: boolean): st
154
257
  L.push('')
155
258
  L.push(' ℹ️ 페르소나는 미포함(--persona 로 opt-in). 스키마 축만 처리했습니다.')
156
259
  }
260
+ if (!gitignore) {
261
+ L.push(' ℹ️ workflow/.gitignore 는 미포함(--gitignore 로 opt-in). 기존 동작은 그대로입니다.')
262
+ }
157
263
  L.push('')
264
+ // 변경 건수 = 파일 복사(writes) + 행 추가(appends). 둘 다 없으면 "변경 없음".
265
+ const changes = plan.writes.length + plan.appends.length
266
+ const optIn = `${persona ? ' --persona' : ''}${gitignore ? ' --gitignore' : ''}`
158
267
  if (!apply) {
159
- if (plan.writes.length > 0) {
160
- L.push(` 적용하려면: npx commitgate sync --apply${persona ? ' --persona' : ''}`)
161
- L.push(` (변경 예정 ${plan.writes.length}개. --apply 후 git diff 로 확인하고 스테이징·커밋하십시오.)`)
268
+ if (changes > 0) {
269
+ L.push(` 적용하려면: npx commitgate sync --apply${optIn}`)
270
+ L.push(` (변경 예정 ${changes}개. --apply 후 git diff 로 확인하고 스테이징·커밋하십시오.)`)
162
271
  } else {
163
272
  L.push(' 변경 없음 — 이미 동기화되어 있습니다.')
164
273
  }
165
- } else if (plan.writes.length > 0) {
166
- L.push(` ✅ ${plan.writes.length}개 파일 갱신. 다음: git diff 로 확인 후 커밋하십시오.`)
274
+ } else if (changes > 0) {
275
+ L.push(` ✅ ${changes}개 파일 갱신. 다음: git diff 로 확인 후 커밋하십시오.`)
167
276
  for (const w of plan.writes) L.push(` git add -- ${w.destRel}`)
277
+ for (const a of plan.appends) L.push(` git add -- ${a.destRel}`)
168
278
  } else {
169
279
  L.push(' 변경 없음 — 이미 동기화되어 있습니다(쓰기 0건).')
170
280
  }
@@ -178,6 +288,7 @@ const STATUS_LABEL: Record<AssetStatus, string> = {
178
288
  'preserved-differs': '차이 감지 → 보존(수동 확인)',
179
289
  'unmanaged-custom': 'custom 경로(unmanaged)',
180
290
  'unmanaged-null': '비활성(unmanaged)',
291
+ 'rules-missing': 'kit 규칙 누락 → 말미에 추가(기존 행 미변경)',
181
292
  }
182
293
 
183
294
  /**
@@ -194,7 +305,7 @@ export function runSync(opts: SyncOptions): SyncPlan {
194
305
  throw new Error('sync 대상이 CommitGate 패키지 자신입니다 — 소비 repo(commitgate를 devDependency로 설치한 곳)에서 실행하세요.')
195
306
 
196
307
  const cfg = loadConfig({ root: targetRoot }) // root 명시 → resolveRoot의 packageRoot fallback 안 탐
197
- const plan = planSync(targetRoot, cfg, opts.persona)
308
+ const plan = planSync(targetRoot, cfg, opts.persona, opts.gitignore === true)
198
309
 
199
310
  if (opts.apply) {
200
311
  for (const w of plan.writes) {
@@ -204,9 +315,15 @@ export function runSync(opts: SyncOptions): SyncPlan {
204
315
  mkdirSync(dirname(destAbs), { recursive: true })
205
316
  copyFileSync(w.srcAbs, destAbs)
206
317
  }
318
+ for (const a of plan.appends) {
319
+ // 동일하게 쓰기 직전 confinement 재검증. append는 **읽고-덧붙여-쓰기**이므로 원본을 다시 읽는다.
320
+ statWritableDest(targetRoot, a.destRel)
321
+ const destAbs = join(targetRoot, a.destRel)
322
+ writeFileSync(destAbs, appendIgnoreRules(readFileSync(destAbs, 'utf8'), a.missing), 'utf8')
323
+ }
207
324
  }
208
325
 
209
- for (const line of renderPlan(plan, opts.apply, opts.persona)) console.log(line)
326
+ for (const line of renderPlan(plan, opts.apply, opts.persona, opts.gitignore === true)) console.log(line)
210
327
  return plan
211
328
  }
212
329
 
@@ -215,6 +332,7 @@ export function parseArgs(argv: string[]): SyncOptions {
215
332
  let dir = process.cwd()
216
333
  let apply = false
217
334
  let persona = false
335
+ let gitignore = false
218
336
  for (let i = 0; i < argv.length; i++) {
219
337
  const a = argv[i]
220
338
  if (a === '--dir') {
@@ -226,6 +344,8 @@ export function parseArgs(argv: string[]): SyncOptions {
226
344
  apply = true
227
345
  } else if (a === '--persona') {
228
346
  persona = true
347
+ } else if (a === '--gitignore') {
348
+ gitignore = true
229
349
  } else if (a === '--dry-run') {
230
350
  apply = false // 기본값이지만 명시 허용
231
351
  } else if (a === '-h' || a === '--help') {
@@ -235,7 +355,7 @@ export function parseArgs(argv: string[]): SyncOptions {
235
355
  throw new Error(`알 수 없는 인자: ${a}`)
236
356
  }
237
357
  }
238
- return { dir: resolve(dir), apply, persona }
358
+ return { dir: resolve(dir), apply, persona, gitignore }
239
359
  }
240
360
 
241
361
  function printHelp(): void {
@@ -245,13 +365,18 @@ function printHelp(): void {
245
365
  npx commitgate sync [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
246
366
  npx commitgate sync --apply [--dir <대상repo>] 스키마 축 재동기화
247
367
  npx commitgate sync --apply --persona 스키마 + 페르소나(부재 복원만) 재동기화
368
+ npx commitgate sync --apply --gitignore 스키마 + workflow/.gitignore 누락 kit 규칙 보강
248
369
 
249
370
  하는 일:
250
371
  workflow/machine.schema.json · workflow/req.config.schema.json 을 설치된 패키지 사본으로 되돌립니다.
251
372
  --persona: 페르소나가 부재면 복원합니다. 내용이 다르면(사용자 편집 가능성) 덮지 않고 보존만 합니다.
373
+ --gitignore: workflow/.gitignore 에 없는 kit 규칙 행만 말미에 추가합니다(기존 행은 미변경·미재정렬).
374
+ 파일이 없으면 kit 템플릿 전체로 생성합니다. 이미 있는 규칙은 건너뜁니다(멱등).
375
+ 0.9.6 이하 설치본은 review-call 로그 규칙이 없어 첫 리뷰 뒤 D10 이 커밋을 막습니다 — 이 옵션이 그 백필입니다.
252
376
 
253
377
  하지 않는 일:
254
- companion skills · workflow/.gitignore · package.json · req:* · req.config.json 은 건드리지 않습니다.
378
+ companion skills · package.json · req:* · req.config.json 은 건드리지 않습니다.
379
+ workflow/.gitignore 는 --gitignore 를 명시할 때만 손대며, 그때도 **덮어쓰지 않고 누락 행만 추가**합니다.
255
380
  캐럿 범위(^0.x)는 자동으로 못 넘깁니다 — 업그레이드(0.x) 문서(github.com/sol5288/commitgate/blob/main/docs/upgrade.md)를 참고해 범위를 먼저 올리세요.
256
381
  `)
257
382
  }
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
- {
2
- "name": "commitgate",
3
- "version": "0.9.6",
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
- "docs:lint": "remark docs README.md README.en.md --quiet --frail"
42
- },
43
- "files": [
44
- "scripts/req",
45
- "scripts/verify-review-overrides.mjs",
46
- "workflow/machine.schema.json",
47
- "workflow/req.config.schema.json",
48
- "workflow/review-persona.md",
49
- "bin",
50
- "templates",
51
- "skills",
52
- "AGENTS.template.md",
53
- "req.config.json.sample",
54
- "README.md",
55
- "README.en.md",
56
- "CHANGELOG.md"
57
- ],
58
- "engines": {
59
- "node": ">=18.17"
60
- },
61
- "dependencies": {
62
- "ajv": "^8.20.0",
63
- "cross-spawn": "^7.0.6",
64
- "semver": "^7.6.3",
65
- "tsx": "^4.19.1"
66
- },
67
- "devDependencies": {
68
- "@types/cross-spawn": "^6.0.6",
69
- "@types/node": "^22.7.4",
70
- "@types/semver": "^7.5.8",
71
- "remark-cli": "^12.0.1",
72
- "remark-validate-links": "^13.1.0",
73
- "typescript": "^5.6.2",
74
- "vitest": "^2.1.2"
75
- }
76
- }
1
+ {
2
+ "name": "commitgate",
3
+ "version": "0.9.8",
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
+ "docs:lint": "remark docs README.md README.en.md --quiet --frail"
42
+ },
43
+ "files": [
44
+ "scripts/req",
45
+ "scripts/verify-review-overrides.mjs",
46
+ "workflow/machine.schema.json",
47
+ "workflow/req.config.schema.json",
48
+ "workflow/review-persona.md",
49
+ "bin",
50
+ "templates",
51
+ "skills",
52
+ "AGENTS.template.md",
53
+ "req.config.json.sample",
54
+ "README.md",
55
+ "README.en.md",
56
+ "CHANGELOG.md"
57
+ ],
58
+ "engines": {
59
+ "node": ">=18.17"
60
+ },
61
+ "dependencies": {
62
+ "ajv": "^8.20.0",
63
+ "cross-spawn": "^7.0.6",
64
+ "semver": "^7.6.3",
65
+ "tsx": "^4.19.1"
66
+ },
67
+ "devDependencies": {
68
+ "@types/cross-spawn": "^6.0.6",
69
+ "@types/node": "^22.7.4",
70
+ "@types/semver": "^7.5.8",
71
+ "remark-cli": "^12.0.1",
72
+ "remark-validate-links": "^13.1.0",
73
+ "typescript": "^5.6.2",
74
+ "vitest": "^2.1.2"
75
+ }
76
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `EvidencePorts`의 실제 구현(fs + git) — REQ-2026-048 phase-3.
3
+ *
4
+ * `lib/evidence.ts`는 leaf(순수)라 fs·git을 모른다. 부수효과는 전부 여기로 모아 두 호출자
5
+ * (`review-codex`의 정상 승인 경로, `req:commit --finalize-design`의 복구 경로)가 **같은 포트**를 쓰게 한다.
6
+ *
7
+ * ⚠️ 이 모듈도 `review-codex`·`req-commit`·`req-doctor`를 import하지 않는다(leaf 유지).
8
+ */
9
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
10
+ import { execFileSync } from 'node:child_process'
11
+ import { createHash } from 'node:crypto'
12
+ import { join, dirname } from 'node:path'
13
+ import { isArchiveFileName } from './scratch'
14
+ import type { EvidencePorts } from './evidence'
15
+
16
+ /** repo-상대 경로 → 절대 경로(구분자 정규화). */
17
+ function abs(root: string, repoRel: string): string {
18
+ return join(root, ...repoRel.replace(/\\/g, '/').split('/'))
19
+ }
20
+
21
+ /**
22
+ * 실 파일시스템·git 기반 포트.
23
+ *
24
+ * @param root 소비 저장소 루트(=`cfg.root`).
25
+ * @param responsesDirRel 티켓 `responses/` repo-상대 경로(아카이브 목록용).
26
+ */
27
+ export function createEvidencePorts(root: string, responsesDirRel: string): EvidencePorts {
28
+ /** 짧은 문자열 출력을 내는 git 호출(파일 내용용 아님). */
29
+ const gitText = (args: string[]): string =>
30
+ execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
31
+
32
+ return {
33
+ readText(repoRel) {
34
+ const p = abs(root, repoRel)
35
+ return existsSync(p) ? readFileSync(p, 'utf8') : null
36
+ },
37
+ writeText(repoRel, content) {
38
+ const p = abs(root, repoRel)
39
+ mkdirSync(dirname(p), { recursive: true })
40
+ writeFileSync(p, content, 'utf8')
41
+ },
42
+ listArchiveNames() {
43
+ const d = abs(root, responsesDirRel)
44
+ return existsSync(d) ? readdirSync(d).filter(isArchiveFileName) : []
45
+ },
46
+ sha256(repoRel) {
47
+ return createHash('sha256').update(readFileSync(abs(root, repoRel))).digest('hex')
48
+ },
49
+ headText(repoRel) {
50
+ try {
51
+ return gitText(['show', `HEAD:${repoRel}`])
52
+ } catch {
53
+ return null // HEAD에 없는 경로
54
+ }
55
+ },
56
+ /**
57
+ * 🔴 **바이트 그대로** 읽어 해시한다.
58
+ *
59
+ * `GitAdapter.exec`는 계약상 결과의 **후행 공백을 제거**한다(`git status --porcelain` 선행 공백 보존이 목적).
60
+ * 그 변환은 파일 내용 해시를 **망가뜨린다**(끝 개행이 사라져 sha가 달라진다). 그래서 이 한 곳만
61
+ * `execFileSync(..., {encoding:'buffer'})`로 원문 바이트를 받는다 — 어댑터 우회가 아니라 **다른 계약**이 필요해서다.
62
+ *
63
+ * 또한 워킹 파일이 아니라 **blob**을 읽는 것이 핵심이다. `core.autocrlf` 환경에서 워킹 파일은 CRLF로
64
+ * 변환될 수 있고, 그러면 커밋된 내용과 sha가 달라져 거짓 불일치가 난다.
65
+ */
66
+ headBlobSha256(repoRel) {
67
+ try {
68
+ const buf = execFileSync('git', ['cat-file', 'blob', `HEAD:${repoRel}`], {
69
+ cwd: root,
70
+ maxBuffer: 64 * 1024 * 1024,
71
+ })
72
+ return createHash('sha256').update(buf).digest('hex')
73
+ } catch {
74
+ return null // HEAD에 없는 경로
75
+ }
76
+ },
77
+ /**
78
+ * `HEAD`에 있는 해당 디렉터리의 아카이브 **repo-상대 경로**(REQ-2026-049 DEC-4).
79
+ *
80
+ * 🔴 `git ls-tree`는 **커밋 트리**를 읽는다 — 워킹 디렉터리를 전혀 보지 않는다. 그래서 "워킹 트리만
81
+ * 고치고 HEAD는 손상된" 경우를 잡는다. `-z`로 NUL 구분해 공백·비ASCII 경로에서도 안전하다
82
+ * (`--name-only`의 기본 출력은 특수문자를 인용해 경로가 변형된다).
83
+ */
84
+ headArchivePaths(responsesDirRel) {
85
+ try {
86
+ const out = execFileSync('git', ['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', responsesDirRel], {
87
+ cwd: root,
88
+ encoding: 'utf8',
89
+ maxBuffer: 64 * 1024 * 1024,
90
+ })
91
+ return out
92
+ .split('\0')
93
+ .map((p) => p.trim())
94
+ .filter((p) => p !== '' && isArchiveFileName(p.split('/').pop() ?? ''))
95
+ } catch {
96
+ return [] // HEAD에 그 경로가 없음
97
+ }
98
+ },
99
+ headCommitSha() {
100
+ return gitText(['rev-parse', 'HEAD']).trim()
101
+ },
102
+ /**
103
+ * 🔴 **pathspec 범위 커밋**. `git add <paths>` 로 새 파일을 추적 대상에 넣은 뒤,
104
+ * `git commit -m <msg> -- <paths>` 로 **그 경로만** 커밋한다.
105
+ *
106
+ * `-- <paths>` 가 핵심이다: pathspec을 주면 git은 그 경로들의 내용만으로 커밋을 만들고
107
+ * **나머지 index는 건드리지 않는다**. 설계 문서를 stage한 채 design 리뷰를 돌리는 정상 경로에서도
108
+ * 그 staged 변경은 evidence 커밋에 섞이지 않고 index에 그대로 남는다.
109
+ */
110
+ commitPaths(paths, message) {
111
+ if (paths.length === 0) return
112
+ execFileSync('git', ['add', '--', ...paths], { cwd: root, encoding: 'utf8' })
113
+ execFileSync('git', ['commit', '-m', message, '--', ...paths], { cwd: root, encoding: 'utf8' })
114
+ },
115
+ }
116
+ }