commitgate 0.9.1 → 0.9.2

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,18 @@
2
2
 
3
3
  이 프로젝트는 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
4
4
 
5
+ ## 0.9.2
6
+
7
+ REQ-2026-039(0.9.1)이 **신규 설치**의 온보딩을 고쳤다면, 0.9.2는 **기존 설치까지 백필**합니다(REQ-2026-040).
8
+ `commitgate quickstart` verb + doctor D21 추가 — 순수 additive라 기존 사용자는 무회귀입니다.
9
+
10
+ - **기존 파일 Quick Start 백필 — `commitgate quickstart` + doctor D21** (REQ-2026-040). REQ-2026-039가 신규
11
+ 설치의 `CLAUDE.md`/`AGENTS.md`에 Quick Start를 넣었지만 seed-once라 **기존 파일엔 닿지 않았습니다**. 새 verb
12
+ `commitgate quickstart`(기본 dry-run·`--apply`)가 기존 파일에 관리 블록(`<!-- commitgate:quickstart -->`)만
13
+ **멱등 주입**하고 블록 밖 내용은 보존합니다(CommonMark 코드펜스 인지·줄바꿈 dominant EOL 정렬). `AGENTS.md`는
14
+ 계약 마커가 있을 때만 대상입니다. `req:doctor` **D21**이 기존 파일에 블록이 없으면 **WARN**(FAIL 아님)으로
15
+ 백필을 안내합니다. sync(whole-file 복사)와 달리 read-merge-write이므로 별도 verb입니다.
16
+
5
17
  ## 0.9.1
6
18
 
7
19
  신규 설치의 온보딩을 개선하는 **문서 릴리스**입니다(REQ-2026-039). 실행 코드·의존성 변경이 없어 기존
package/README.en.md CHANGED
@@ -62,7 +62,7 @@ git commit -m "chore: install commitgate"
62
62
  | `.cursor/rules/commitgate.mdc` | Cursor (`alwaysApply`) |
63
63
  | `CLAUDE.md` | Claude Code (always loaded) — created only if absent |
64
64
 
65
- > **On a fresh install, `CLAUDE.md` and `AGENTS.md` open with a self-sufficient Quick Start** (clarify the four-box requirement → `req:new` → the `req:next` loop). Because these are always-loaded channels, the agent picks the correct first action on the first request without reading the full contract. An existing `CLAUDE.md`/`AGENTS.md` is preserved the Quick Start is not injected in that case.
65
+ > **On a fresh install, `CLAUDE.md` and `AGENTS.md` open with a self-sufficient Quick Start** (clarify the four-box requirement → `req:new` → the `req:next` loop). Because these are always-loaded channels, the agent picks the correct first action on the first request without reading the full contract. An existing `CLAUDE.md`/`AGENTS.md` is preserved, so the block is not injected there **to backfill an existing file, run `npx commitgate quickstart --apply`** (inserts only the managed block, preserves the rest, idempotent).
66
66
 
67
67
  Just give the agent a requirement.
68
68
 
@@ -304,7 +304,19 @@ npx commitgate sync --apply --persona # persona too (restore if missing; your e
304
304
 
305
305
  **③ If you are on an older (vendored) install**, follow up with `migrate` below to move to the Stage B runtime.
306
306
 
307
- > In short: install `commitgate@latest` `commitgate sync --apply` (if needed) `commitgate migrate`.
307
+ **④ The Quick Start block also does not reach existing files automatically (0.9.2+).** A fresh install puts the
308
+ onboarding Quick Start at the top of `CLAUDE.md`/`AGENTS.md`, but `init` is seed-once, so it is **not applied to
309
+ files that already existed**. After upgrading, backfill existing files with `commitgate quickstart`:
310
+
311
+ ```sh
312
+ npx commitgate quickstart # plan only (dry-run — see what would change)
313
+ npx commitgate quickstart --apply # inject only the managed block (preserves the rest, idempotent)
314
+ ```
315
+
316
+ - `AGENTS.md` is targeted only when it carries the CommitGate contract marker. Absent files are left untouched.
317
+ - `req:doctor`'s **D21** WARNs when an existing file is missing the Quick Start block (it never blocks the commit).
318
+
319
+ > In short: install `commitgate@latest` → `commitgate sync --apply` → `commitgate quickstart --apply` → (if needed) `commitgate migrate`.
308
320
 
309
321
  ---
310
322
 
@@ -519,6 +531,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
519
531
  | `npx commitgate init --strict` | Treat integrity warnings as install failures — stops before writing any file |
520
532
  | `npx commitgate init --no-agent-entrypoints` | Skip `.claude/`, `.cursor/`, and `CLAUDE.md` |
521
533
  | `npx commitgate sync [--apply] [--persona]` | Re-sync vendored **schema-axis** assets (machine/req.config schema) to the installed package copy after an upgrade (plan-only by default). `--persona` **restores a missing persona only** (never overwrites your edits). See [Upgrading (0.x)](#upgrading-0x) |
534
+ | `npx commitgate quickstart [--apply]` | Backfill the Quick Start block into an existing `CLAUDE.md`/`AGENTS.md` (plan-only by default). Inserts only the managed block, preserves the rest, idempotent. `AGENTS.md` only when it carries the contract marker |
522
535
  | `npx commitgate migrate [--apply]` | Move an older vendored install to the runtime package (plan-only by default, non-destructive) |
523
536
  | `npx commitgate uninstall` | Preview the removal plan (read-only — deletes nothing) |
524
537
  | `npm uninstall -D commitgate` | Remove the runtime |
package/README.md CHANGED
@@ -62,7 +62,7 @@ git commit -m "chore: install commitgate"
62
62
  | `.cursor/rules/commitgate.mdc` | Cursor (`alwaysApply`) |
63
63
  | `CLAUDE.md` | Claude Code (항상 로드) — 부재 시에만 생성 |
64
64
 
65
- > **신규 설치에서 `CLAUDE.md`와 `AGENTS.md`는 맨 앞에 자립형 Quick Start**(요구 4칸 확인 → `req:new` → `req:next` 루프)를 담습니다. 항상 읽히는 채널이라, 에이전트가 계약 본문을 더 읽지 않아도 첫 요청에서 올바른 첫 행동을 고릅니다. 기존 `CLAUDE.md`/`AGENTS.md`가 있으면 보존되어 이때 Quick Start는 새로 주입되지 않습니다.
65
+ > **신규 설치에서 `CLAUDE.md`와 `AGENTS.md`는 맨 앞에 자립형 Quick Start**(요구 4칸 확인 → `req:new` → `req:next` 루프)를 담습니다. 항상 읽히는 채널이라, 에이전트가 계약 본문을 더 읽지 않아도 첫 요청에서 올바른 첫 행동을 고릅니다. 기존 `CLAUDE.md`/`AGENTS.md`가 있으면 보존되므로 새로 주입되지 않습니다 **기존 파일에도 넣으려면 `npx commitgate quickstart --apply`**(관리 블록만 삽입·나머지 보존·멱등).
66
66
 
67
67
  에이전트에게 요구사항만 주면 됩니다.
68
68
 
@@ -303,7 +303,19 @@ npx commitgate sync --apply --persona # 페르소나도 함께(부재면 복원
303
303
 
304
304
  **③ 예전(vendored) 설치본이면** 이어서 아래 `migrate`로 Stage B 전환까지 하세요.
305
305
 
306
- > 정리: `commitgate@latest` 설치 `commitgate sync --apply` (필요 시) `commitgate migrate`.
306
+ **④ Quick Start 블록도 기존 파일엔 자동으로 닿습니다(0.9.2+).** 신규 설치는 `CLAUDE.md`/`AGENTS.md` 앞에
307
+ 온보딩 Quick Start를 넣지만, init은 seed-once라 **이미 있던 파일**엔 반영되지 않습니다. 업그레이드 후 기존
308
+ 파일에 넣으려면 `commitgate quickstart`로 백필하세요:
309
+
310
+ ```sh
311
+ npx commitgate quickstart # 계획만 출력(dry-run — 무엇이 바뀔지 확인)
312
+ npx commitgate quickstart --apply # 관리 블록만 주입(블록 밖 내용 보존·멱등)
313
+ ```
314
+
315
+ - `AGENTS.md`는 CommitGate 계약 마커가 있을 때만 대상입니다. 부재 파일은 건드리지 않습니다.
316
+ - `req:doctor`의 **D21**이 기존 파일에 Quick Start 블록이 없으면 **WARN**으로 알려 줍니다(커밋은 막지 않습니다).
317
+
318
+ > 정리: `commitgate@latest` 설치 → `commitgate sync --apply` → `commitgate quickstart --apply` → (필요 시) `commitgate migrate`.
307
319
 
308
320
  ---
309
321
 
@@ -516,6 +528,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
516
528
  | `npx commitgate init --strict` | 정합성 경고를 설치 실패로 처리 (gitignore된 계약 포인터, 설치 커밋을 안전하게 만들 수 없는 워킹트리 등) — 파일을 하나도 쓰기 전에 중단 |
517
529
  | `npx commitgate init --no-agent-entrypoints` | `.claude/`·`.cursor/`·`CLAUDE.md` 설치 건너뛰기 |
518
530
  | `npx commitgate sync [--apply] [--persona]` | 업그레이드 후 vendored **스키마 축**(machine·req.config schema)을 설치 패키지 사본으로 재동기화 (기본: 계획만). `--persona`는 페르소나 **부재 복원만**(사용자 수정본 미훼손). 자세히는 [업그레이드 (0.x)](#업그레이드-0x) |
531
+ | `npx commitgate quickstart [--apply]` | 기존 `CLAUDE.md`/`AGENTS.md`에 Quick Start 블록을 멱등 주입(기본: 계획만). 관리 블록만 삽입·나머지 보존. `AGENTS.md`는 계약 마커 있을 때만. seed-once로 기존 파일에 안 닿는 [REQ-2026-039]를 백필 |
519
532
  | `npx commitgate migrate [--apply]` | 예전 vendored 설치본 → 런타임 패키지 전환 (기본: 계획만, 비파괴) |
520
533
  | `npx commitgate uninstall` | 제거 계획 확인 (읽기 전용 — 아무것도 지우지 않음) |
521
534
  | `npm uninstall -D commitgate` | 런타임 제거 |
package/bin/dispatch.mjs CHANGED
@@ -21,6 +21,7 @@ export const VERB_MODULES = {
21
21
  uninstall: 'uninstall.ts',
22
22
  migrate: 'migrate.ts',
23
23
  sync: 'sync.ts',
24
+ quickstart: 'quickstart.ts',
24
25
  init: 'init.ts',
25
26
  }
26
27
 
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * commitgate quickstart — 기존 CLAUDE.md/AGENTS.md에 Quick Start 블록을 opt-in·멱등 백필 (REQ-2026-040).
4
+ *
5
+ * REQ-2026-039가 템플릿에 Quick Start를 넣었지만 init은 **seed-once**라 기존 파일엔 닿지 않는다. 이 모듈은
6
+ * 마커(`<!-- commitgate:quickstart -->`) 기반으로 **관리 블록만** 삽입/치환하고 나머지 내용은 보존한다.
7
+ * sync(whole-file copy)와 달리 read-merge-write이므로 별도 verb다(설계 D1).
8
+ *
9
+ * phase-1(REQ-2026-040): 순수 함수(extract/inject). phase-2: CLI verb·plan/apply·confinement·doctor 연동 헬퍼.
10
+ */
11
+ import { existsSync, lstatSync, readFileSync, writeFileSync, realpathSync } from 'node:fs'
12
+ import { resolve, join } from 'node:path'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { PACKAGE_ROOT, statWritableDest, assertGitWorkTree, AGENTS_CONTRACT_MARKER } from './init'
15
+
16
+ export const QUICKSTART_MARKER_OPEN = '<!-- commitgate:quickstart -->'
17
+ export const QUICKSTART_MARKER_CLOSE = '<!-- /commitgate:quickstart -->'
18
+ /** 마커 쌍(포함) 매칭. 비탐욕 — 첫 close에서 끝난다. */
19
+ const QS_RE = /<!-- commitgate:quickstart -->[\s\S]*?<!-- \/commitgate:quickstart -->/
20
+
21
+ const toLf = (s: string): string => s.replace(/\r\n/g, '\n')
22
+
23
+ /** 템플릿 본문에서 마커 포함 Quick Start 블록을 뽑는다. 부재면 null. */
24
+ export function extractQuickstartBlock(templateBody: string): string | null {
25
+ const m = templateBody.match(QS_RE)
26
+ return m ? m[0] : null
27
+ }
28
+
29
+ export type InjectAction = 'noop' | 'updated' | 'inserted'
30
+ export interface InjectResult {
31
+ content: string
32
+ action: InjectAction
33
+ /** action==='inserted'일 때만: 삽입 위치(plan 표시용). */
34
+ insertAt?: 'after-heading' | 'top'
35
+ }
36
+
37
+ /**
38
+ * 파일의 **dominant** EOL(CRLF 개수 vs standalone LF 개수 비교). CRLF가 한 번 섞였다고 CRLF로 보지
39
+ * 않는다(design-r01 P1 — LF 우세 파일에 CRLF 블록을 넣으면 혼합 줄바꿈이 된다).
40
+ */
41
+ function dominantEol(s: string): '\r\n' | '\n' {
42
+ const crlf = (s.match(/\r\n/g) ?? []).length
43
+ const standaloneLf = (s.match(/\n/g) ?? []).length - crlf
44
+ return crlf > standaloneLf ? '\r\n' : '\n'
45
+ }
46
+
47
+ /** block의 줄바꿈을 dominant EOL에 맞춘다(혼합 줄바꿈 방지). */
48
+ function matchEol(block: string, eol: '\r\n' | '\n'): string {
49
+ const lf = toLf(block)
50
+ return eol === '\r\n' ? lf.replace(/\n/g, '\r\n') : lf
51
+ }
52
+
53
+ /**
54
+ * 첫 top-level `# ` heading **줄 종결자 뒤**의 오프셋. **fenced code block 안의 `# `는 제외**한다
55
+ * (design-r01 P1). 펜스 판정은 CommonMark를 따른다(design-r02 P1): 여는 펜스의 **문자(`` ` `` / `~`)와
56
+ * 길이**를 기억하고, **같은 문자·opening 이상 길이·info string 없는** 줄만 닫기로 본다 — 그래서
57
+ * `` ``` `` 펜스 안의 `~~~`(또는 더 짧은 펜스)는 닫기가 아니라 코드 내용이다. heading이 없으면 null.
58
+ */
59
+ function afterFirstHeadingOffset(s: string): number | null {
60
+ let fenceChar: '`' | '~' | null = null // null = 펜스 밖
61
+ let fenceLen = 0
62
+ let i = 0
63
+ while (i < s.length) {
64
+ const nl = s.indexOf('\n', i)
65
+ const lineEnd = nl === -1 ? s.length : nl
66
+ const nextStart = nl === -1 ? s.length : nl + 1
67
+ let line = s.slice(i, lineEnd)
68
+ if (line.endsWith('\r')) line = line.slice(0, -1)
69
+ if (fenceChar === null) {
70
+ const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
71
+ const fence = open?.[1]
72
+ if (fence !== undefined) {
73
+ // CommonMark: backtick 펜스의 info string엔 backtick이 올 수 없다(인라인 코드 오인 방지). tilde는 무관.
74
+ const invalidBacktick = fence.charAt(0) === '`' && (open?.[2] ?? '').includes('`')
75
+ if (!invalidBacktick) {
76
+ fenceChar = fence.charAt(0) as '`' | '~'
77
+ fenceLen = fence.length
78
+ }
79
+ // invalidBacktick이면 유효 opening 아님 — 일반 줄(``` 로 시작하니 heading도 아님).
80
+ } else if (/^# /.test(line)) {
81
+ return nextStart
82
+ }
83
+ } else {
84
+ // 펜스 안: 같은 문자·opening 이상 길이·(trailing 공백 외)info string 없는 닫기만 닫는다.
85
+ const close = /^ {0,3}([`~]{3,})[ \t]*$/.exec(line)?.[1]
86
+ if (close !== undefined && close === fenceChar.repeat(close.length) && close.length >= fenceLen) {
87
+ fenceChar = null
88
+ fenceLen = 0
89
+ }
90
+ }
91
+ i = nextStart
92
+ }
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * 기존 파일에 Quick Start 블록을 주입한다(순수 — IO 없음). **관리 블록(마커 사이)만** 건드리고
98
+ * 블록 밖 내용은 바이트 보존한다.
99
+ * - 마커 有 & (줄바꿈 정규화 후) 동일 → `noop`.
100
+ * - 마커 有 & 다름 → in-place 치환(`updated`).
101
+ * - 마커 無 → 삽입(`inserted`). 첫 top-level `# ` heading(코드펜스 밖) **바로 뒤**, 없으면 **파일 맨 앞**.
102
+ * 삽입 블록의 줄바꿈은 파일 dominant EOL을 따른다.
103
+ */
104
+ export function injectQuickstart(fileContent: string, block: string): InjectResult {
105
+ const existing = fileContent.match(QS_RE)
106
+ const eol = dominantEol(fileContent)
107
+ const eolBlock = matchEol(block, eol)
108
+ if (existing) {
109
+ if (toLf(existing[0]) === toLf(block)) return { content: fileContent, action: 'noop' }
110
+ // 함수 replacer — block 안의 `$`가 특수치환으로 해석되지 않게 한다.
111
+ return { content: fileContent.replace(QS_RE, () => eolBlock), action: 'updated' }
112
+ }
113
+ const at = afterFirstHeadingOffset(fileContent)
114
+ if (at !== null) {
115
+ const content = fileContent.slice(0, at) + eol + eolBlock + eol + eol + fileContent.slice(at)
116
+ return { content, action: 'inserted', insertAt: 'after-heading' }
117
+ }
118
+ return { content: eolBlock + eol + eol + fileContent, action: 'inserted', insertAt: 'top' }
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────── CLI verb (phase-2) ──
122
+
123
+ /** 백필 대상 = always-loaded 두 채널. AGENTS.md는 계약 마커가 있을 때만 대상(계약 아닌 파일 미접촉). */
124
+ const TARGET_FILES = ['CLAUDE.md', 'AGENTS.md'] as const
125
+ const TEMPLATE_REL = 'templates/CLAUDE.template.md'
126
+
127
+ export interface QuickstartOptions {
128
+ dir: string
129
+ apply: boolean
130
+ }
131
+ export type FileAction = 'noop' | 'replace' | 'insert' | 'skip'
132
+ export interface FilePlan {
133
+ rel: string
134
+ action: FileAction
135
+ insertAt?: 'after-heading' | 'top' // action==='insert'만
136
+ reason?: string // action==='skip'만
137
+ }
138
+ export interface QuickstartPlan {
139
+ targetRoot: string
140
+ files: FilePlan[]
141
+ writes: { rel: string; content: string }[]
142
+ }
143
+
144
+ /** targetRoot·PACKAGE_ROOT 동일성 판정용 정규화(sync.canonical과 동일 기법). */
145
+ function canonical(p: string): string {
146
+ try {
147
+ return resolve(realpathSync.native(p))
148
+ } catch {
149
+ return resolve(p)
150
+ }
151
+ }
152
+
153
+ /** 패키지 템플릿에서 SSOT Quick Start 블록을 읽는다. 부재면 throw(REQ-2026-039가 보장). */
154
+ export function shippedQuickstartBlock(): string {
155
+ const block = extractQuickstartBlock(readFileSync(join(PACKAGE_ROOT, TEMPLATE_REL), 'utf8'))
156
+ if (block === null) throw new Error(`템플릿에 Quick Start 블록이 없습니다: ${TEMPLATE_REL}`)
157
+ return block
158
+ }
159
+
160
+ type TargetState = { kind: 'absent' } | { kind: 'unsafe' } | { kind: 'file'; content: string }
161
+ /**
162
+ * 대상 파일 상태를 confinement-안전하게 읽는다. `lstat`로 존재(심링크 포함) 판정하고, `statWritableDest`가
163
+ * null이면 안전하지 않은 경로(symlink escape 등)로 보고 미접촉한다(REQ-2026-024 재사용 — 재구현 금지).
164
+ */
165
+ function readSafeTarget(root: string, rel: string): TargetState {
166
+ const abs = join(root, rel)
167
+ try {
168
+ lstatSync(abs)
169
+ } catch {
170
+ return { kind: 'absent' }
171
+ }
172
+ if (statWritableDest(root, rel) === null) return { kind: 'unsafe' }
173
+ return { kind: 'file', content: readFileSync(abs, 'utf8') }
174
+ }
175
+
176
+ /** 소비 repo에서 Quick Start 블록이 없는 always-loaded 파일 목록(doctor D21용). 부재·계약아님·최신은 제외. */
177
+ export function missingQuickstartFiles(root: string): string[] {
178
+ const missing: string[] = []
179
+ for (const rel of TARGET_FILES) {
180
+ const st = readSafeTarget(root, rel)
181
+ if (st.kind !== 'file') continue
182
+ if (rel === 'AGENTS.md' && !st.content.includes(AGENTS_CONTRACT_MARKER)) continue // 계약 아님 → 미접촉
183
+ if (!st.content.includes(QUICKSTART_MARKER_OPEN)) missing.push(rel)
184
+ }
185
+ return missing
186
+ }
187
+
188
+ /** 백필 계획(순수 판정 — 쓰기 없음). 파일별 action + 삽입 위치 + skip 사유. */
189
+ export function planQuickstart(targetRoot: string, block: string): QuickstartPlan {
190
+ const files: FilePlan[] = []
191
+ const writes: { rel: string; content: string }[] = []
192
+ for (const rel of TARGET_FILES) {
193
+ const st = readSafeTarget(targetRoot, rel)
194
+ if (st.kind === 'absent') {
195
+ files.push({ rel, action: 'skip', reason: '부재 — 생성은 init 소관(백필 대상 아님)' })
196
+ continue
197
+ }
198
+ if (st.kind === 'unsafe') {
199
+ files.push({ rel, action: 'skip', reason: '안전하지 않은 경로(symlink 등) — 미접촉' })
200
+ continue
201
+ }
202
+ if (rel === 'AGENTS.md' && !st.content.includes(AGENTS_CONTRACT_MARKER)) {
203
+ files.push({ rel, action: 'skip', reason: 'CommitGate 계약 마커 없음 — 미접촉' })
204
+ continue
205
+ }
206
+ const r = injectQuickstart(st.content, block)
207
+ if (r.action === 'noop') {
208
+ files.push({ rel, action: 'noop' })
209
+ continue
210
+ }
211
+ files.push({ rel, action: r.action === 'updated' ? 'replace' : 'insert', insertAt: r.insertAt })
212
+ writes.push({ rel, content: r.content })
213
+ }
214
+ return { targetRoot, files, writes }
215
+ }
216
+
217
+ /** 계획을 사람이 읽는 줄 배열로(shell 연산자 미사용 — sync.renderPlan 관례). */
218
+ export function renderQuickstartPlan(plan: QuickstartPlan, apply: boolean): string[] {
219
+ const GLYPH: Record<FileAction, string> = { noop: '=', replace: '~', insert: '+', skip: '·' }
220
+ const LABEL: Record<FileAction, string> = {
221
+ noop: '최신(변경 없음)',
222
+ replace: '블록 상이 → 교체',
223
+ insert: '블록 없음 → 삽입',
224
+ skip: '건너뜀',
225
+ }
226
+ const L: string[] = ['']
227
+ L.push(`[commitgate quickstart] 기존 파일 Quick Start 백필 ${apply ? '(--apply: 파일을 씁니다)' : '계획 (dry-run — 아무것도 쓰지 않습니다)'}`)
228
+ L.push(` 대상: ${plan.targetRoot}`)
229
+ L.push('')
230
+ for (const f of plan.files) {
231
+ const pos = f.action === 'insert' ? ` (${f.insertAt === 'top' ? '파일 맨 앞' : '# 제목 뒤'})` : ''
232
+ const why = f.reason ? ` — ${f.reason}` : ''
233
+ L.push(` ${GLYPH[f.action]} ${f.rel} — ${LABEL[f.action]}${pos}${why}`)
234
+ }
235
+ L.push('')
236
+ if (!apply) {
237
+ if (plan.writes.length > 0) {
238
+ L.push(' ⚠️ --apply 전에는 아무것도 쓰지 않습니다. 적용하려면: npx commitgate quickstart --apply')
239
+ L.push(` (변경 예정 ${plan.writes.length}개. --apply 후 git diff 로 확인하고 스테이징·커밋하십시오.)`)
240
+ } else {
241
+ L.push(' 변경 없음 — 이미 최신이거나 대상이 없습니다.')
242
+ }
243
+ } else if (plan.writes.length > 0) {
244
+ L.push(` ✅ ${plan.writes.length}개 파일 갱신. 다음: git diff 로 확인 후 커밋하십시오.`)
245
+ for (const w of plan.writes) L.push(` git add -- ${w.rel}`)
246
+ } else {
247
+ L.push(' 변경 없음 — 이미 최신이거나 대상이 없습니다(쓰기 0건).')
248
+ }
249
+ return L
250
+ }
251
+
252
+ /**
253
+ * 실행. 기본 plan(dry-run, 쓰기 0건), `--apply`에서만 쓴다.
254
+ * 🔴 `targetRoot===PACKAGE_ROOT` 하드 거부(sync 선례) + `assertGitWorkTree` + 쓰기 직전 confinement 재검증.
255
+ */
256
+ export function runQuickstart(opts: QuickstartOptions): QuickstartPlan {
257
+ const targetRoot = resolve(opts.dir)
258
+ if (!existsSync(targetRoot)) throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
259
+ assertGitWorkTree(targetRoot)
260
+ if (canonical(targetRoot) === canonical(PACKAGE_ROOT))
261
+ throw new Error('quickstart 대상이 CommitGate 패키지 자신입니다 — 소비 repo(commitgate를 devDependency로 설치한 곳)에서 실행하세요.')
262
+
263
+ const block = shippedQuickstartBlock()
264
+ const plan = planQuickstart(targetRoot, block)
265
+
266
+ if (opts.apply) {
267
+ for (const w of plan.writes) {
268
+ statWritableDest(targetRoot, w.rel) // 쓰기 직전 confinement 재검증(TOCTOU 최소화 — 단일 경로 재사용)
269
+ writeFileSync(join(targetRoot, w.rel), w.content)
270
+ }
271
+ }
272
+
273
+ for (const line of renderQuickstartPlan(plan, opts.apply)) console.log(line)
274
+ return plan
275
+ }
276
+
277
+ /** CLI 파싱(fail-closed). `--flag=value` 미지원, 미지 토큰은 throw(sync/init 관례). */
278
+ export function parseArgs(argv: string[]): QuickstartOptions {
279
+ let dir = process.cwd()
280
+ let apply = false
281
+ for (let i = 0; i < argv.length; i++) {
282
+ const a = argv[i]
283
+ if (a === '--dir') {
284
+ const v = argv[i + 1]
285
+ if (v === undefined) throw new Error('--dir 값 누락')
286
+ dir = v
287
+ i++
288
+ } else if (a === '--apply') {
289
+ apply = true
290
+ } else if (a === '--dry-run') {
291
+ apply = false // 기본값이지만 명시 허용
292
+ } else if (a === '-h' || a === '--help') {
293
+ printHelp()
294
+ process.exit(0)
295
+ } else {
296
+ throw new Error(`알 수 없는 인자: ${String(a)}`)
297
+ }
298
+ }
299
+ return { dir: resolve(dir), apply }
300
+ }
301
+
302
+ function printHelp(): void {
303
+ console.log(`commitgate quickstart — 기존 CLAUDE.md/AGENTS.md에 Quick Start 블록을 백필
304
+
305
+ 사용법:
306
+ npx commitgate quickstart [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
307
+ npx commitgate quickstart --apply [--dir <대상repo>] Quick Start 블록 주입
308
+
309
+ 하는 일:
310
+ 기존 CLAUDE.md(존재)·AGENTS.md(계약 마커 존재)에 관리 블록(<!-- commitgate:quickstart -->)만
311
+ 삽입/교체하고 블록 밖 내용은 보존합니다. 멱등(재실행=변경 없음). 부재 파일은 건드리지 않습니다(init 소관).
312
+
313
+ 하지 않는 일:
314
+ 파일 생성 · 계약 마커 없는 AGENTS.md · 블록 밖 내용 수정 · symlink escape 경로 쓰기.
315
+ `)
316
+ }
317
+
318
+ export function runCli(argv: string[]): void {
319
+ try {
320
+ runQuickstart(parseArgs(argv))
321
+ } catch (err) {
322
+ console.error(`commitgate quickstart: ${err instanceof Error ? err.message : String(err)}`)
323
+ process.exitCode = 1
324
+ }
325
+ }
326
+
327
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
328
+ if (isMain) runCli(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitgate",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
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",
@@ -26,6 +26,7 @@ import {
26
26
  } from './review-codex'
27
27
  import { loadConfig, packageRoot, stripBom, DEFAULTS, type ResolvedConfig } from './lib/config'
28
28
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
29
+ import { missingQuickstartFiles } from '../../bin/quickstart'
29
30
 
30
31
  // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — config 부재 시 현재 동작 보존).
31
32
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
@@ -93,6 +94,10 @@ export interface DoctorInputs {
93
94
  packageRootDiffers?: boolean
94
95
  schemaPathIsDefault?: boolean
95
96
  installedVersion?: string | null
97
+ // D21(REQ-2026-040): 기존 always-loaded 파일 중 Quick Start 블록이 없는 것(bin/quickstart.missingQuickstartFiles).
98
+ // undefined = 미계산(2-arg/legacy) → OK. [] = 없음/최신/대상없음 → OK. 비어있지 않음 → WARN.
99
+ // dev/dogfood(packageRootDiffers===false)면 D20처럼 skip. optional이어야 테스트 base 리터럴이 안 깨진다.
100
+ quickstartMissing?: string[]
96
101
  }
97
102
 
98
103
  /**
@@ -460,6 +465,23 @@ export function runChecks(inp: DoctorInputs): Check[] {
460
465
  })
461
466
  }
462
467
 
468
+ // D21(REQ-2026-040): 기존 CLAUDE.md/AGENTS.md에 Quick Start 블록 부재 진단. **WARN — 절대 FAIL 아님**
469
+ // (D20과 동일 근거: req:commit이 이 doctor를 하드 게이트로 spawn하므로 FAIL이면 커밋이 벽돌이 된다).
470
+ // seed-once라 REQ-2026-039 이전 설치본/기존 파일엔 신규 블록이 닿지 않는다 — 백필 필요를 알릴 뿐 막지 않는다.
471
+ if (inp.packageRootDiffers === false) {
472
+ c.push({ id: 'D21', level: 'OK', msg: 'Quick Start 백필 점검 불요(dev repo/dogfood — packageRoot === config root)' })
473
+ } else if (inp.quickstartMissing === undefined) {
474
+ c.push({ id: 'D21', level: 'OK', msg: 'Quick Start 백필 점검 불요(2-arg/미계산)' })
475
+ } else if (inp.quickstartMissing.length === 0) {
476
+ c.push({ id: 'D21', level: 'OK', msg: '기존 always-loaded 파일에 Quick Start 블록 있음(또는 대상 없음)' })
477
+ } else {
478
+ c.push({
479
+ id: 'D21',
480
+ level: 'WARN',
481
+ msg: `${inp.quickstartMissing.join(', ')} 에 Quick Start 블록이 없습니다 — \`commitgate quickstart --apply\` 로 백필하세요. seed-once라 신규 블록이 기존 파일엔 자동으로 닿지 않습니다(REQ-2026-040).`,
482
+ })
483
+ }
484
+
463
485
  return c
464
486
  }
465
487
 
@@ -676,6 +698,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
676
698
  packageRootDiffers: packageRoot() !== cfg.root,
677
699
  schemaPathIsDefault: cfg.schemaPathAbs === resolve(cfg.root, DEFAULTS.schemaPath), // 정규화 절대경로 비교(동치 상대경로 포함)
678
700
  installedVersion: safeReadVersion(join(packageRoot(), 'package.json')),
701
+ quickstartMissing: missingQuickstartFiles(cfg.root),
679
702
  }
680
703
 
681
704
  const checks = runChecks(inp)