commitgate 0.9.0 → 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.
@@ -8,6 +8,28 @@
8
8
  > ⚠️ 첫 줄의 `<!-- commitgate:contract -->` 마커를 지우지 마라. `.claude/`·`.cursor/` 진입점 파일이
9
9
  > "이 `AGENTS.md`가 CommitGate 계약인가"를 그 마커로 판별하고, `npx commitgate`도 부재 시 경고한다.
10
10
 
11
+ <!-- commitgate:quickstart -->
12
+ ## CommitGate — 빠른 시작 (첫 요청에서 이대로)
13
+
14
+ 이 저장소의 코드/문서 변경은 CommitGate REQ 워크플로로만 처리한다. 일반 구현으로 바로 커밋하지 않는다.
15
+
16
+ 1. 요청이 [무엇 / 왜 / 제약 / 완료 기준]으로 정리되지 않았으면 **먼저 사용자에게 확인**한다. 추측해서 채우지 않는다.
17
+ 2. `package.json`의 `req:new` 스크립트와 `req.config.json`의 `packageManager`를 확인해, 이 저장소의
18
+ 패키지매니저 실행 형식으로 `req:new <슬러그>`를 실행해 REQ 티켓·브랜치를 만든다. 형식을 추측하지 않는다.
19
+ 3. 그다음부터 매 단계는 `req:next <REQ-id>`의 출력만 따른다. `kind`가 정본이다:
20
+ - `RUN` → 출력된 명령을 그대로 실행하고 다시 `req:next`
21
+ - `AGENT` → 구현·검증·명시적 `git add` 후 다시 `req:next`
22
+ - `AWAIT_HUMAN` → **멈춘다.** 출력된 승인 문장을 그대로 받기 전에는 진행하지 않는다
23
+ - `DONE` → 이 티켓 종료. 통합·릴리즈는 별도 통제점
24
+ - `BLOCKED` → 사람에게 보고. **같은 리뷰를 재시도하지 않는다**
25
+ 4. 리뷰가 `NEEDS_FIX`면 지적(findings)을 고친 뒤 다시 `req:next`로 돌아간다.
26
+ 5. `state.json`·`responses/`는 직접 `git add` 하지 않는다 — 도구가 관리한다.
27
+ 6. 커밋은 `req:next`가 `RUN`으로 지시할 때 `req:commit`으로만 한다. 스스로 `req:commit`을 호출하지 않는다.
28
+ 직접 `git commit`은 CommitGate 자체 스캐폴딩 산출물(`init`·`migrate`·`sync`가 쓴 파일)을 커밋할 때만 쓴다.
29
+ <!-- /commitgate:quickstart -->
30
+
31
+ > 위는 빠른 시작 요약이다. 아래가 정본 계약(절대 규칙·통제점·승인 문장)이다.
32
+
11
33
  ## 역할
12
34
 
13
35
  - **Builder(Claude 등)**: REQ 티켓 단위로 설계→구현→테스트. `req:new`로 티켓·브랜치 생성.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
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
+
17
+ ## 0.9.1
18
+
19
+ 신규 설치의 온보딩을 개선하는 **문서 릴리스**입니다(REQ-2026-039). 실행 코드·의존성 변경이 없어 기존
20
+ 사용자는 무회귀이고, **신규 설치에만** 반영됩니다(seed-once — 기존 `CLAUDE.md`/`AGENTS.md`는 보존).
21
+
22
+ - **온보딩 Quick Start — always-loaded 템플릿 자립화** (REQ-2026-039). 신규 설치가 생성하는
23
+ `CLAUDE.md`(Claude Code가 항상 로드)와 `AGENTS.md`(Codex·Cursor가 항상 읽는 계약) **앞부분**에,
24
+ 첫 요청에서 올바른 첫 행동을 고를 수 있는 자립형 Quick Start 블록(`req:new` → `req:next` 루프 · 5
25
+ kind · `state.json`/`responses` staging 금지 · `git commit` 직접 사용 예외)을 넣습니다. 이전엔 이
26
+ 앞부분이 "`AGENTS.md`를 읽어라"는 이정표라, 에이전트가 계약 존재는 알아도 **첫 조작에서 멈추곤**
27
+ 했습니다. 두 템플릿의 블록은 **바이트 동일**(단위 테스트로 강제 — 한쪽만 고치는 drift 방지).
28
+ **신규 설치에만 반영**(seed-once — 기존 `CLAUDE.md`/`AGENTS.md`는 보존). 기존 파일에 Quick Start를
29
+ 주입하는 UX는 후속(REQ-040).
30
+
5
31
  ## 0.9.0
6
32
 
7
33
  phase 자동 커밋 opt-in(REQ-2026-037)과 업그레이드 자산 skew 감지·복구(REQ-2026-038)가 핵심입니다. 둘 다 0.8.x 위
package/README.en.md CHANGED
@@ -62,6 +62,8 @@ 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, 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
+
65
67
  Just give the agent a requirement.
66
68
 
67
69
  ```text
@@ -302,7 +304,19 @@ npx commitgate sync --apply --persona # persona too (restore if missing; your e
302
304
 
303
305
  **③ If you are on an older (vendored) install**, follow up with `migrate` below to move to the Stage B runtime.
304
306
 
305
- > 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`.
306
320
 
307
321
  ---
308
322
 
@@ -517,6 +531,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
517
531
  | `npx commitgate init --strict` | Treat integrity warnings as install failures — stops before writing any file |
518
532
  | `npx commitgate init --no-agent-entrypoints` | Skip `.claude/`, `.cursor/`, and `CLAUDE.md` |
519
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 |
520
535
  | `npx commitgate migrate [--apply]` | Move an older vendored install to the runtime package (plan-only by default, non-destructive) |
521
536
  | `npx commitgate uninstall` | Preview the removal plan (read-only — deletes nothing) |
522
537
  | `npm uninstall -D commitgate` | Remove the runtime |
package/README.md CHANGED
@@ -62,6 +62,8 @@ 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`가 있으면 보존되므로 새로 주입되지 않습니다 — **기존 파일에도 넣으려면 `npx commitgate quickstart --apply`**(관리 블록만 삽입·나머지 보존·멱등).
66
+
65
67
  에이전트에게 요구사항만 주면 됩니다.
66
68
 
67
69
  ```text
@@ -301,7 +303,19 @@ npx commitgate sync --apply --persona # 페르소나도 함께(부재면 복원
301
303
 
302
304
  **③ 예전(vendored) 설치본이면** 이어서 아래 `migrate`로 Stage B 전환까지 하세요.
303
305
 
304
- > 정리: `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`.
305
319
 
306
320
  ---
307
321
 
@@ -514,6 +528,7 @@ npm run req:commit -- 2026-001 --run --message-file commit-message.txt
514
528
  | `npx commitgate init --strict` | 정합성 경고를 설치 실패로 처리 (gitignore된 계약 포인터, 설치 커밋을 안전하게 만들 수 없는 워킹트리 등) — 파일을 하나도 쓰기 전에 중단 |
515
529
  | `npx commitgate init --no-agent-entrypoints` | `.claude/`·`.cursor/`·`CLAUDE.md` 설치 건너뛰기 |
516
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]를 백필 |
517
532
  | `npx commitgate migrate [--apply]` | 예전 vendored 설치본 → 런타임 패키지 전환 (기본: 계획만, 비파괴) |
518
533
  | `npx commitgate uninstall` | 제거 계획 확인 (읽기 전용 — 아무것도 지우지 않음) |
519
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.0",
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)
@@ -1,16 +1,27 @@
1
- # 프로젝트 지침
2
-
3
- <!-- 이 파일은 `npx commitgate`가 CLAUDE.md가 없을 때만 생성한 템플릿입니다. 자유롭게 수정하세요. -->
4
-
5
- ## CommitGate
6
-
7
- 이 저장소는 **CommitGate**를 쓴다. 코드 변경은 REQ 티켓 단위로 묶이고, Codex가 승인한 staged tree만 커밋된다.
8
-
9
- 코드를 커밋하게 되는 요청은 일반 구현으로 처리하지 말고 이 워크플로를 따른다.
10
-
11
- - **계약 정본**: 저장소 루트의 [`AGENTS.md`](./AGENTS.md). 절대 규칙·통제점·승인 문장이 거기 있다.
12
- (`<!-- commitgate:contract -->` 마커가 없으면 CommitGate 계약이 아니다 init이 함께 설치한 루트의 `AGENTS.commitgate.md`를 계약으로 읽고, 사용자에게 `AGENTS.md`로의 병합을 요청하라.)
13
- - **다음 행동은 추측하지 않는다**: `req:next <REQ-id>`가 알려 준다.
14
- `RUN`은 그대로 실행, `AGENT`는 작업 수행 `git add`, `AWAIT_HUMAN`은 **멈추고 승인 문장을 그대로** 받는다.
15
- 워크플로 명령은 저장소의 **패키지매니저 실행 형식**으로 돌린다. `req:next`의 `RUN` 출력이 정확한 형태를 그대로 보여 준다.
16
- - 자세한 진입 절차는 `/req` 슬래시 커맨드 또는 `.claude/skills/commitgate/SKILL.md`에 있다.
1
+ # 프로젝트 지침
2
+
3
+ <!-- 이 파일은 `npx commitgate`가 CLAUDE.md가 없을 때만 생성한 템플릿입니다. 자유롭게 수정하세요. -->
4
+
5
+ <!-- commitgate:quickstart -->
6
+ ## CommitGate — 빠른 시작 (첫 요청에서 이대로)
7
+
8
+ 이 저장소의 코드/문서 변경은 CommitGate REQ 워크플로로만 처리한다. 일반 구현으로 바로 커밋하지 않는다.
9
+
10
+ 1. 요청이 [무엇 / 왜 / 제약 / 완료 기준]으로 정리되지 않았으면 **먼저 사용자에게 확인**한다. 추측해서 채우지 않는다.
11
+ 2. `package.json`의 `req:new` 스크립트와 `req.config.json`의 `packageManager`를 확인해, 저장소의
12
+ 패키지매니저 실행 형식으로 `req:new <슬러그>`를 실행해 REQ 티켓·브랜치를 만든다. 형식을 추측하지 않는다.
13
+ 3. 그다음부터 단계는 `req:next <REQ-id>`의 출력만 따른다. `kind`가 정본이다:
14
+ - `RUN` 출력된 명령을 그대로 실행하고 다시 `req:next`
15
+ - `AGENT` → 구현·검증·명시적 `git add` 다시 `req:next`
16
+ - `AWAIT_HUMAN` **멈춘다.** 출력된 승인 문장을 그대로 받기 전에는 진행하지 않는다
17
+ - `DONE` → 이 티켓 종료. 통합·릴리즈는 별도 통제점
18
+ - `BLOCKED` → 사람에게 보고. **같은 리뷰를 재시도하지 않는다**
19
+ 4. 리뷰가 `NEEDS_FIX`면 지적(findings)을 고친 뒤 다시 `req:next`로 돌아간다.
20
+ 5. `state.json`·`responses/`는 직접 `git add` 하지 않는다 — 도구가 관리한다.
21
+ 6. 커밋은 `req:next`가 `RUN`으로 지시할 때 `req:commit`으로만 한다. 스스로 `req:commit`을 호출하지 않는다.
22
+ 직접 `git commit`은 CommitGate 자체 스캐폴딩 산출물(`init`·`migrate`·`sync`가 쓴 파일)을 커밋할 때만 쓴다.
23
+ <!-- /commitgate:quickstart -->
24
+
25
+ - 세부 규칙·통제점·승인 문장의 **정본**은 루트 [`AGENTS.md`](./AGENTS.md)이다.
26
+ (`<!-- commitgate:contract -->` 마커가 없으면 CommitGate 계약이 아니다 — init이 함께 설치한 루트의 `AGENTS.commitgate.md`를 계약으로 읽고, 사용자에게 `AGENTS.md`로의 병합을 요청하라.)
27
+ - Claude Code에서는 `/req` 슬래시 커맨드로 시작할 수도 있다. 진입 절차 상세는 `.claude/skills/commitgate/SKILL.md`.