geobuke-code 0.2.9 → 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/README.md +71 -6
- package/dist/cli.js +279 -18
- package/dist/config.js +10 -0
- package/dist/golden.js +45 -0
- package/dist/hook.js +57 -4
- package/dist/install.js +28 -0
- package/dist/judge.js +7 -3
- package/dist/metrics.js +9 -0
- package/dist/repos.js +10 -3
- package/dist/review.js +62 -0
- package/dist/version.js +12 -0
- package/package.json +1 -1
- package/skills/gate/SKILL.md +12 -3
package/README.md
CHANGED
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
|
|
20
20
|
게이트는 *완전 구현*을 요구하지 않는다. 케이스가 다뤄지기 시작했거나 명시 defer되면 통과한다.
|
|
21
21
|
|
|
22
|
+
그 게이트 위에 운영을 돕는 얇은 층이 붙는다:
|
|
23
|
+
|
|
24
|
+
- **누락 케이스 일괄 분류** — 차단이 도출한 형제 케이스를 번호 체크리스트로 받아 한 번에 승인(spec)/미룸(defer) ([`gbc gate review`](#누락-케이스-일괄-분류-gbc-gate-review))
|
|
25
|
+
- **크로스-repo 가시성** — 등록한 다른 repo의 미해결 defer 요약 + 게이트 hook 건강성 롤업("회사 repo에서 게이트가 조용히 안 먹는다"를 한 명령으로 진단) ([크로스-repo](#크로스-repo-가시성))
|
|
26
|
+
- **판정 드리프트 회귀락** — 실제 판정을 캡처해두고 모델/프롬프트/SDK 변화 후 재판정해 pass↔block 뒤집힘을 잡는 로컬 pre-flight ([`gbc gate snapshot`](#판정-드리프트-회귀락-gbc-gate-snapshot))
|
|
27
|
+
- **관측 계측(M1~M3)** — 게이트 적중·재호출·통과 후 churn. 여러 repo는 `gbc metrics --all`로 병합 ([계측](#계측-m1m3))
|
|
28
|
+
|
|
22
29
|
## 설치
|
|
23
30
|
|
|
24
31
|
```bash
|
|
@@ -100,11 +107,11 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
100
107
|
| **코드 변경 직전** | PreToolUse (`Edit\|Write\|MultiEdit`) | 명세 ↔ 변경 ↔ defer 대조 → 통과(침묵)/차단(시나리오 도출 지시)/fail-open |
|
|
101
108
|
| **작업단위당 1회** | (PreToolUse 캐시) | 같은 명세 해시 내에선 첫 편집만 판정, 이후 통과 → 매 편집 지연 회피 |
|
|
102
109
|
| **응답 종료** | Stop | 계측 flush(`events.jsonl`) + 미해결 defer가 있으면 리마인드(매 대화 종료마다). 거슬리면 `gbc defer mute`(또는 `/gbc-mute` 스킬)로 끈다 — SessionStart 진입 알림은 유지 |
|
|
103
|
-
| **업데이트 필요 시** | (PreToolUse·SessionStart) | hook 구버전(②) 또는 신버전 출시(①)면 갱신 안내. PreToolUse는 세션당 1회(`systemMessage` 비차단)
|
|
110
|
+
| **업데이트 필요 시** | (PreToolUse·SessionStart) | hook 구버전(②) 또는 신버전 출시(①)면 갱신 안내. PreToolUse는 세션당 1회(`systemMessage` 비차단) — **통과된 작업단위(cached-skip) 편집에도 표시**(0.3.0: 평상 작업 대부분이 cached-skip이라, 여기서 빠지면 배너가 거의 안 떴음). SessionStart는 진입 시 표시(모델 컨텍스트). `gbc status`는 캐시만 갱신하고 안내는 **표시하지 않는다**(명시 진단 명령). 게이트 통과/차단 동작은 불변 |
|
|
104
111
|
|
|
105
112
|
> 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 매 대화 종료(Stop) defer 리마인드만 끄려면 `gbc defer mute`(영속, 해제 `unmute` · 스킬 `/gbc-mute`) — 진입 알림은 남는다. 업데이트 안내만 끄려면 `GBC_NO_UPDATE_NOTICE=1`.
|
|
106
113
|
> 프로젝트 hook이 구식이거나(SessionStart 누락·옛 명령) 새 버전이 나오면 gbc가 감지해 **`gbc update`**(전역 최신 + 현재 프로젝트 재init 한방) 또는 수동 `npm i -g geobuke-code@latest → gbc init --yes`를 안내한다. 단 안내는 **이미 hook이 등록된 프로젝트**(=한 번이라도 `gbc init`을 한 코호트)에만 도달한다 — 전혀 init하지 않은 프로젝트엔 실행할 hook이 없어 구조적으로 알릴 수 없다(gbc는 전역 hook을 깔지 않는다).
|
|
107
|
-
> **업데이트 안내(①)는 네트워크를 게이트 핫패스에 들이지 않는다**: `~/.gbc/version-check.json` 캐시만 비교하고, 갱신 fetch는
|
|
114
|
+
> **업데이트 안내(①)는 네트워크를 게이트 핫패스에 들이지 않는다**: `~/.gbc/version-check.json` 캐시만 비교하고, 갱신 fetch는 안전한 비-핫패스에서만 짧은 타임아웃(1.5s)으로. ⓐSessionStart는 캐시가 stale이면 **표시 전에 갱신**해 신버전이 그 세션에 바로 뜬다(1세션 지연 없음). ⓑ**PreToolUse는 judge를 도는 편집(cache-miss)에서 캐시가 stale이면 refresh를 judge와 *병렬*로 건다**(0.3.0) — judge가 ≥1.5s라 지연 0이고, 사용자가 `gbc status`를 직접 치지 않아도 캐시가 최신이 된다. **cached-skip 핫패스에는 네트워크를 절대 넣지 않는다.** 조회 실패는 조용히 무시(fail-silent)되어 게이트 결정에 영향이 없다. 캐시 TTL 24h.
|
|
108
115
|
|
|
109
116
|
### 시나리오 도출 루프 (수기 입력 불필요)
|
|
110
117
|
|
|
@@ -117,6 +124,19 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
117
124
|
- **도출**은 코딩 에이전트 본체(Opus, 대화 맥락 보유)가, **게이트 판정**은 haiku가 한다 — 두 작업/두 모델 분리. gbc는 모델 계층을 소유하지 않는다(판단용 작은 호출만).
|
|
118
125
|
- **사용자 검증은 양보 불가**다 — 같은 에이전트가 도출+구현까지 자동으로 하면 자기 시나리오만 통과시키는 고무도장이 된다. 승인 없는 자동 등록을 금지한다.
|
|
119
126
|
|
|
127
|
+
### 누락 케이스 일괄 분류 (`gbc gate review`)
|
|
128
|
+
|
|
129
|
+
명세가 있는데 **형제 케이스를 침묵 누락**해 차단되면, 판정이 도출한 누락 케이스들이 `.gbc/pending-review.json`에 기록된다. 케이스가 여러 개일 때 하나씩 `gbc spec add`/`gbc defer add`를 반복하지 않고 **체크리스트로 한 번에 분류**한다:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
gbc gate review # 누락 케이스를 번호 목록으로 (사용자에게 제시·검증)
|
|
133
|
+
gbc gate review --spec 1 3 --defer 2 # 1,3은 승인→spec / 2는 미룸→defer (한 번에)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- 승인(`--spec`)은 `.gbc/spec.md`에, 미룸(`--defer`)은 defer 레지스트리에 등록하고 펜딩을 비운다. 한 케이스가 양쪽에 걸리면 **spec 우선**(이중 등록 방지). ref는 `번호|텍스트|all`(defer 명령과 동일).
|
|
137
|
+
- 분류 후 같은 편집을 재시도하면 등록된 케이스 기준으로 재판정된다. 여기서도 **사용자 검증이 분류 전제** — 도출된 케이스를 사용자에게 보여주고 승인받은 뒤 등록한다.
|
|
138
|
+
- 펜딩은 "가장 최근 차단의 도출"이라 다음 차단이 덮어쓴다. 단건이면 종전대로 지금 변경에서 직접 다루거나 `gbc defer add`로 미뤄도 된다.
|
|
139
|
+
|
|
120
140
|
## 지연(latency)과 트랜스포트
|
|
121
141
|
|
|
122
142
|
판정은 작은 LLM 호출이다. 두 트랜스포트:
|
|
@@ -134,7 +154,7 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
134
154
|
|
|
135
155
|
| 명령 | 설명 |
|
|
136
156
|
|---|---|
|
|
137
|
-
| `gbc init` | hook + `/gate` · `/gbc-mute` 스킬 설치 |
|
|
157
|
+
| `gbc init` | hook + `/gate` · `/gbc-mute` 스킬 설치 + 크로스-repo 레지스트리 자동등록(opt-out: `--no-register`) |
|
|
138
158
|
| `gbc update` | 전역 최신 설치(`npm i -g …@latest`) + 현재 프로젝트 재init 한방. `--dry-run`으로 실행 명령만 미리보기 |
|
|
139
159
|
| `gbc status` | 게이트 상태 + 로드된 명세 + Stop 리마인드 음소거 여부 |
|
|
140
160
|
| `gbc defer add "<케이스>"` | 케이스를 명시적으로 미루기 (→ open) |
|
|
@@ -147,9 +167,13 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
147
167
|
| `gbc spec show` | 등록된 케이스 목록 |
|
|
148
168
|
| `gbc spec clear` | 명세 비우기(작업단위 종료) |
|
|
149
169
|
| `gbc gate reset` | 작업단위 게이트 리셋 |
|
|
150
|
-
| `gbc
|
|
170
|
+
| `gbc gate review` | 차단이 도출한 누락 케이스 체크리스트 보기 |
|
|
171
|
+
| `gbc gate review --spec <ref> --defer <ref>` | 누락 케이스 일괄 분류(승인→spec / 미룸→defer) |
|
|
172
|
+
| `gbc gate snapshot <on\|off\|status\|list\|clear>` | 골든셋 캡처 토글·조회(판정 드리프트 회귀락) |
|
|
173
|
+
| `gbc gate snapshot replay [--samples N]` | 골든 케이스 재판정(temp 0)·드리프트 시 exit 1 |
|
|
174
|
+
| `gbc metrics [--all] [--json]` | 계측 리포트(M1~M3). `--all`=등록 repo들의 events.jsonl 병합 집계 |
|
|
151
175
|
| `gbc repos add [경로]` | 크로스-repo 레지스트리에 추가(생략 시 현재 폴더) |
|
|
152
|
-
| `gbc repos list` | 등록된 repo + 각 repo의 미해결 defer 수 |
|
|
176
|
+
| `gbc repos list` | 등록된 repo + 각 repo의 미해결 defer 수 + **게이트 건강성**(hook 부재/구식 코호트) |
|
|
153
177
|
| `gbc repos remove [경로]` | 레지스트리에서 제거 |
|
|
154
178
|
|
|
155
179
|
우회: `GBC_NO_GATE=1` (계측됨 — 우회 자체가 게이트 가치 측정 데이터).
|
|
@@ -158,6 +182,8 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
158
182
|
|
|
159
183
|
여러 repo를 오가며 작업할 때, **다른 repo에 걸린 미완 작업**을 그 repo를 열지 않고도 인지하기 위한 기능이다. 세션 진입(SessionStart) 시 현재 repo의 미해결 defer 상세에 더해, **등록된 다른 repo들의 미해결 defer 요약**을 한 줄로 환기한다.
|
|
160
184
|
|
|
185
|
+
`gbc init`을 하면 그 repo는 **레지스트리에 자동 등록**된다(opt-out: `gbc init --no-register`). 따라서 보통은 아래를 따로 칠 필요 없이, init한 repo들이 서로의 미완 작업을 자동으로 환기한다. 수동 관리가 필요할 때만:
|
|
186
|
+
|
|
161
187
|
```bash
|
|
162
188
|
# 감시할 repo를 글로벌 레지스트리(~/.gbc/repos.json)에 등록 (각 repo에서 1회, 또는 경로 지정)
|
|
163
189
|
gbc repos add # 현재 폴더
|
|
@@ -182,10 +208,29 @@ gbc repos list # 등록 현황 + 각 repo 미해결 defer 수
|
|
|
182
208
|
|
|
183
209
|
> CLI에서 repo별 alias(`alias cc-x='cd <dir> && claude'`)를 쓴다면, alias에 `gbc repos add . 2>/dev/null;`를 끼워 **여는 repo를 자동 등록**할 수 있다(등록은 멱등).
|
|
184
210
|
|
|
211
|
+
### 게이트 건강성 롤업 (`gbc repos list`)
|
|
212
|
+
|
|
213
|
+
"회사 repo에서 게이트가 조용히 안 먹는다"를 한 명령으로 진단한다. `gbc repos list`는 각 등록 repo의 `.claude/settings.json`을 읽어 hook 등록 상태를 표시한다:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
📁 등록된 repo 3개:
|
|
217
|
+
[○깨끗] /path/to/healthy
|
|
218
|
+
[○깨끗] /path/to/old ⚠️SessionStart누락 ← 0.2.1 이하 init 코호트
|
|
219
|
+
[●미해결2] /path/to/broken ⚠️게이트hook부재 ← PreToolUse 게이트가 아예 없음(조용히 죽음)
|
|
220
|
+
|
|
221
|
+
⚠️ 게이트 hook 부재/SessionStart 누락 repo는 해당 repo에서 'gbc init --yes' 재실행으로 복구하세요.
|
|
222
|
+
(크로스-repo는 hook *등록 여부*만 검사 — 명령 freshness[설치경로 의존]는 각 repo에서 'gbc status'로 확인)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- **검사 대상**: 게이트 hook(`PreToolUse`)·SessionStart hook의 **등록 여부**. 둘 다 cliPath 없이 결정론적으로 판정된다.
|
|
226
|
+
- **검사 안 하는 것**: hook 명령의 *freshness*(구버전 prefix 등). gbc는 단일 전역 설치지만 각 repo의 hook 명령엔 설치 시점의 절대경로가 구워져 있어, 현재 런타임 경로로 타 repo를 stale 판정하면 false-positive가 난다. 명령이 구식인지는 그 repo에서 `gbc status`로 확인한다(진짜 사후대조 freshness는 A-mode 과제).
|
|
227
|
+
|
|
185
228
|
## 계측 (M1~M3)
|
|
186
229
|
|
|
187
230
|
게이트는 모든 결정을 `.gbc/events.jsonl`(append-only, 메타데이터만 — 코드 본문 미기록)에 기록한다. `gbc metrics`로 집계를 본다. 끄려면 `GBC_NO_METRICS=1`.
|
|
188
231
|
|
|
232
|
+
여러 repo의 게이트 ROI를 한 번에 보려면 `gbc metrics --all` — 등록된 repo들의 `events.jsonl`을 병합 집계한다. 병합 시 각 이벤트의 `specHash`를 repo 경로로 태깅해 **repo간 boilerplate 명세 해시 충돌**을 막는다(태깅 없이 합치면 한 repo의 통과 뒤 다른 repo의 변이가 M1 churn으로 오집계된다; M2/M3는 세션 UUID 키라 원래 안전). symlink로 등록된 경로는 거부한다(등록 경로 밖 임의 디렉터리 읽기 차단).
|
|
233
|
+
|
|
189
234
|
| 지표 | 관측 | B-모드 신뢰도 |
|
|
190
235
|
|---|---|---|
|
|
191
236
|
| **M2** 게이트 적중 vs 도중발견 | 차단이 잡은 누락 케이스 수 vs `defer add`로 도중 등록된 수 | **강** (defer-registry와 1:1) |
|
|
@@ -194,11 +239,31 @@ gbc repos list # 등록 현황 + 각 repo 미해결 defer 수
|
|
|
194
239
|
|
|
195
240
|
> ⚠️ **진짜 M1**(통과 후 시나리오 위반율)은 게이트가 엔진 출력을 채점하는 **사후 대조**가 필요하다 — 이는 후속 A(standalone) 모드 영역이다. B-커널(hook)은 churn 약신호만 관측한다. `events.jsonl` 원시 로그는 그때 그대로 재사용된다.
|
|
196
241
|
|
|
242
|
+
## 판정 드리프트 회귀락 (`gbc gate snapshot`)
|
|
243
|
+
|
|
244
|
+
게이트 판정은 LLM(haiku)이라 **모델 업그레이드·프롬프트 수정·SDK 변경**이 같은 편집에 대한 통과/차단을 조용히 바꿀 수 있다. 골든셋은 실제 판정을 캡처해두고 나중에 재판정해 그 드리프트를 잡는다.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
gbc gate snapshot on # 캡처 모드 ON (opt-in)
|
|
248
|
+
# … 평소처럼 작업 — judge가 평가하는 편집이 .gbc/golden.json에 기록된다 …
|
|
249
|
+
gbc gate snapshot list # 캡처된 케이스 확인 (판정·도구·편집 머리말)
|
|
250
|
+
gbc gate snapshot off # 캡처 종료
|
|
251
|
+
|
|
252
|
+
# 나중에(모델/gbc 업그레이드 후 등) 드리프트 점검:
|
|
253
|
+
gbc gate snapshot replay # 각 케이스를 temp 0으로 재판정, 캡처 시점과 비교
|
|
254
|
+
gbc gate snapshot replay --samples 5 # 케이스당 5회 모달 판정(잔여 비결정 흡수)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- **하드 신호 = 판정 뒤집힘(pass↔block)만.** 하나라도 뒤집히면 `replay`는 **exit 1**(로컬 pre-flight 게이트로 사용 가능). `missing[]` 변화는 LLM 자유서술이라 정보용으로만 표시하고 절대 실패시키지 않는다.
|
|
258
|
+
- **결정성**: replay는 judge를 `temperature 0`으로 재실행한다(핫패스 게이트는 불변). `claude -p` 폴백 트랜스포트는 temperature 핀을 지원하지 않아 best-effort다 — 직접 API 키가 있을 때 가장 안정적. temp 0도 bit-stable은 아니므로 `--samples N`으로 다수결 모달을 쓸 수 있다.
|
|
259
|
+
- **캡처 시점**: judge가 *실제로 평가한* cache-miss 편집만 기록된다(cached-skip·fail-open 제외). 특정 편집을 캡처하려면 `gbc gate reset` 후 그 편집을 수행한다.
|
|
260
|
+
- **⚠️ 로컬 전용(privacy)**: `golden.json`은 정규화된 **편집 본문**을 담는다 — `events.jsonl`이 불변식으로 절대 저장하지 않는 내용이다. `.gbc/`는 gitignore이므로 이 골든셋은 **로컬 드리프트 점검**이지 커밋되는 CI 스위트가 아니다. 공유 CI로 쓰려면 편집 본문을 커밋하는 privacy 트레이드오프를 명시적으로 감수해야 한다.
|
|
261
|
+
|
|
197
262
|
## 정직한 한계
|
|
198
263
|
|
|
199
264
|
- 사후 대조가 아닌 **구현 전 게이트**다 — "도중 탈선"은 못 잡는다(설계상 후속 C 영역).
|
|
200
265
|
- 판정은 LLM이라 100% 아니다. **사람이 변이 전 케이스를 리뷰/편집하는 pause**가 진짜 가치다.
|
|
201
|
-
-
|
|
266
|
+
- scope = **B-커널**(CC-native hook + defer-registry + /gate) + 그 위 운영층: 누락 케이스 일괄 분류·크로스-repo 가시성/건강성·판정 드리프트 회귀락·관측 계측(M1~M3). **standalone TUI·엔진 래핑 추출 모드·진짜 사후대조 M1**은 후속 A(public) 영역이다 — B-커널은 hook-게스트라 "구현 전" 게이트일 뿐, 통과 후 실제 산출물을 채점하지 않는다.
|
|
202
267
|
- **검증 상태**: 게이트 판정 품질은 **양 트랜스포트 모두 회귀 8/8(FP0 FN0)**. 직접 API(haiku) 경로 실측 **평균 1.7s**(1.1–2.5s), claude -p 폴백 ~18s. 직접 API용 게이트 프롬프트는 최소화하면서 정확도를 유지하도록 "동작 편집 vs 비-동작 편집" 2단계 분류로 튜닝했다(`ANTHROPIC_API_KEY=… node dist/eval/regression.js`로 재현).
|
|
203
268
|
- **fail-open**: 판정 호출이 실패하면(키 오류·네트워크 등) 게이트는 안전하게 통과시킨다(개발 차단 방지). 단 fail-open 통과는 작업단위 캐시에서 제외되고(다음 편집 재판정), `systemMessage` 경고 + `.gbc/failopen.log` 계측으로 드러난다(조용한 무력화 방지).
|
|
204
269
|
|
package/dist/cli.js
CHANGED
|
@@ -4,17 +4,20 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, } from "node:fs";
|
|
7
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, lstatSync, } from "node:fs";
|
|
8
8
|
import { runPreToolUse, runStop, runSessionStart } from "./hook.js";
|
|
9
9
|
import { loadPlanSpec, computeSpecHash, addSpecCase, readSpecCases, clearSpec } from "./spec.js";
|
|
10
10
|
import { loadState, resetGate } from "./state.js";
|
|
11
11
|
import { addDefer, loadDefers, resolveDefer, startDefer, reopenDefer } from "./defer.js";
|
|
12
12
|
import { loadRepos, addRepo, removeRepo } from "./repos.js";
|
|
13
|
-
import {
|
|
13
|
+
import { readPendingReview, clearPendingReview, resolveRefs } from "./review.js";
|
|
14
|
+
import { isStopHintMuted, setStopHintMuted, isGoldenCapture, setGoldenCapture } from "./config.js";
|
|
15
|
+
import { loadGolden, clearGolden, diffVerdict, summarizeReplay } from "./golden.js";
|
|
14
16
|
import { selectedTransport } from "./judge.js";
|
|
15
|
-
import { buildPreCommand, normalizeHooks, ensureSessionStartHook, DEV_PLACEHOLDER } from "./install.js";
|
|
17
|
+
import { buildPreCommand, normalizeHooks, ensureSessionStartHook, DEV_PLACEHOLDER, assessRepoHealth } from "./install.js";
|
|
18
|
+
import { readProjectSettings } from "./notice.js";
|
|
16
19
|
import { isCacheStale, readVersionCache, refreshVersionCache, } from "./version.js";
|
|
17
|
-
import { logEvent, parseEvents, computeMetrics } from "./metrics.js";
|
|
20
|
+
import { logEvent, parseEvents, computeMetrics, tagEventsWithRepo } from "./metrics.js";
|
|
18
21
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
19
22
|
const PKG_ROOT = join(dirname(CLI_PATH), ".."); // dist/cli.js → 패키지 루트
|
|
20
23
|
/** 설치된 패키지 버전(업데이트 안내 비교 기준). 읽기 실패 시 "". */
|
|
@@ -69,6 +72,8 @@ async function cmdInit(args) {
|
|
|
69
72
|
// --dev: hook 명령에 절대경로(CLI_PATH) 대신 ${CLAUDE_PROJECT_DIR} placeholder를 굽는다.
|
|
70
73
|
// geobuke-code 자기 repo 도그푸딩 전용(dist 위치가 옮겨다녀도 안 깨짐). 기본(false)은 절대경로.
|
|
71
74
|
const dev = args.includes("--dev");
|
|
75
|
+
// --no-register: 크로스-repo 레지스트리(~/.gbc/repos.json) 자동등록 opt-out(기본=등록).
|
|
76
|
+
const noRegister = args.includes("--no-register");
|
|
72
77
|
const hookPath = dev ? DEV_PLACEHOLDER : CLI_PATH;
|
|
73
78
|
const claudeDir = join(cwd, ".claude");
|
|
74
79
|
const settingsPath = join(claudeDir, "settings.json");
|
|
@@ -82,6 +87,7 @@ async function cmdInit(args) {
|
|
|
82
87
|
- 기존 settings.json 있으면 백업: settings.json.bak-<시각>
|
|
83
88
|
2) ${join(claudeDir, "skills")} 에 ${skillNames.map((n) => `/${n}`).join(", ")} 스킬 설치
|
|
84
89
|
3) hook 명령: ${buildPreCommand(hookPath)}${dev ? " (--dev: ${CLAUDE_PROJECT_DIR} placeholder)" : ""}
|
|
90
|
+
4) ${noRegister ? "크로스-repo 레지스트리 등록 생략 (--no-register)" : "이 repo를 크로스-repo 레지스트리(~/.gbc/repos.json)에 등록 (opt-out: --no-register)"}
|
|
85
91
|
${hasApiKey()
|
|
86
92
|
? " (~/.gbc/api-key 감지됨 → 빠른 haiku API 경로로 동작)"
|
|
87
93
|
: " (~/.gbc/api-key 없음 → claude -p 폴백. 빠른 경로 원하면 키 파일 생성)"}
|
|
@@ -155,6 +161,20 @@ ${hasApiKey()
|
|
|
155
161
|
? " (ANTHROPIC_API_KEY 설정 시 직접 API로 ~1–3s, 미설정 시 claude -p 폴백 ~13–20s)"
|
|
156
162
|
: ""}
|
|
157
163
|
계획 명세는 .gbc/spec.md 에 작성하세요(없으면 시나리오 미지정으로 차단 → 도출·검증 루프 발동: 에이전트가 요청에서 시나리오를 도출해 사용자 검증 후 'gbc spec add'로 등록).`);
|
|
164
|
+
// 크로스-repo 레지스트리 자동등록(~/.gbc/repos.json, 멱등 dedup) — 등록된 타 repo의 미해결
|
|
165
|
+
// defer를 SessionStart에 환기(0.2.9 buildCrossRepoHint)하려면 레지스트리가 차 있어야 한다.
|
|
166
|
+
// 이 repo 자신은 cwd 제외라 즉시 가시성 0이지만, N개 누적되면 서로의 잔여를 환기(passive fill).
|
|
167
|
+
// ~/.gbc append만(별 네임스페이스) → 프로젝트 .claude/settings.json 미접촉(install-safe 보존).
|
|
168
|
+
// --no-register로 opt-out. 등록 실패는 init 본체를 깨지 않는다(best-effort).
|
|
169
|
+
if (!noRegister) {
|
|
170
|
+
try {
|
|
171
|
+
addRepo(cwd);
|
|
172
|
+
console.log(` + 크로스-repo 레지스트리 등록 (~/.gbc/repos.json, opt-out: --no-register)`);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* 레지스트리 쓰기 실패는 무시(fail-silent) */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
158
178
|
// 설치 직후 버전 캐시 seed — 신버전 안내(①)가 "설치만 하고 init 안 한" 코호트에도
|
|
159
179
|
// 신뢰성 있게 동작하도록(SessionStart 없는 환경의 유일한 seed 지점일 수 있음). best-effort.
|
|
160
180
|
try {
|
|
@@ -299,30 +319,227 @@ function cmdSpec(args) {
|
|
|
299
319
|
}
|
|
300
320
|
}
|
|
301
321
|
// ---------- gbc gate ----------
|
|
302
|
-
function cmdGate(args) {
|
|
322
|
+
async function cmdGate(args) {
|
|
323
|
+
const cwd = process.cwd();
|
|
303
324
|
if (args[0] === "reset") {
|
|
304
|
-
const cwd = process.cwd();
|
|
305
325
|
logCli(cwd, "gate-reset", curHash(cwd));
|
|
306
326
|
resetGate(cwd);
|
|
307
327
|
console.log("🐢 작업단위 게이트 리셋 — 다음 편집에서 다시 발동합니다.");
|
|
308
328
|
}
|
|
329
|
+
else if (args[0] === "review") {
|
|
330
|
+
cmdGateReview(cwd, args.slice(1));
|
|
331
|
+
}
|
|
332
|
+
else if (args[0] === "snapshot") {
|
|
333
|
+
await cmdGateSnapshot(cwd, args.slice(1));
|
|
334
|
+
}
|
|
309
335
|
else {
|
|
310
|
-
console.error("사용: gbc gate reset");
|
|
336
|
+
console.error("사용: gbc gate <reset|review|snapshot>");
|
|
311
337
|
process.exit(1);
|
|
312
338
|
}
|
|
313
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* 골든셋 캡처 + 판정 드리프트 회귀락(A2). 캡처는 opt-in(hook이 judge 출력을 .gbc/golden.json에 로컬
|
|
342
|
+
* 저장 — edit 본문 포함이라 gitignore·로컬 pre-flight 전용). replay는 각 케이스를 judge temp 0으로
|
|
343
|
+
* 재판정해 캡처 시점과 비교, 판정 뒤집힘(decisionFlip)이 있으면 exit 1(로컬 회귀 게이트).
|
|
344
|
+
*/
|
|
345
|
+
async function cmdGateSnapshot(cwd, args) {
|
|
346
|
+
const sub = args[0];
|
|
347
|
+
if (sub === "on") {
|
|
348
|
+
setGoldenCapture(cwd, true);
|
|
349
|
+
console.log("🐢 골든셋 캡처 ON — 이제 judge가 평가하는 cache-miss 편집이 .gbc/golden.json에 기록됩니다.\n" +
|
|
350
|
+
" ⚠️ 캡처되는 편집 본문은 'gbc gate snapshot replay' 시 Anthropic API(haiku)로 전송됩니다.\n" +
|
|
351
|
+
" (특정 편집을 캡처하려면 'gbc gate reset' 후 그 편집 수행. 끄기: gbc gate snapshot off)");
|
|
352
|
+
}
|
|
353
|
+
else if (sub === "off") {
|
|
354
|
+
setGoldenCapture(cwd, false);
|
|
355
|
+
console.log("🐢 골든셋 캡처 OFF.");
|
|
356
|
+
}
|
|
357
|
+
else if (sub === "status" || sub === undefined) {
|
|
358
|
+
const cases = loadGolden(cwd);
|
|
359
|
+
console.log(`🐢 골든셋 — 캡처 ${isGoldenCapture(cwd) ? "ON" : "OFF"} · 케이스 ${cases.length}건 (.gbc/golden.json, 로컬 전용)`);
|
|
360
|
+
}
|
|
361
|
+
else if (sub === "list") {
|
|
362
|
+
const cases = loadGolden(cwd);
|
|
363
|
+
if (cases.length === 0) {
|
|
364
|
+
console.log("골든셋 비어 있음. 'gbc gate snapshot on'으로 캡처를 시작하세요.");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
console.log(`🐢 골든셋 ${cases.length}건:`);
|
|
368
|
+
for (const c of cases) {
|
|
369
|
+
const head = c.edit.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
370
|
+
console.log(` [${c.expected.verdict}] ${c.tool} ${c.id} "${head}…"`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else if (sub === "clear") {
|
|
374
|
+
clearGolden(cwd);
|
|
375
|
+
console.log("🐢 골든셋 비움.");
|
|
376
|
+
}
|
|
377
|
+
else if (sub === "replay") {
|
|
378
|
+
await cmdGateSnapshotReplay(cwd, args.slice(1));
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
console.error("사용: gbc gate snapshot <on|off|status|list|clear|replay>");
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** golden 케이스를 judge temp 0으로 재판정해 드리프트를 본다. --samples N=모달 판정(잔여 노이즈 흡수). */
|
|
386
|
+
async function cmdGateSnapshotReplay(cwd, args) {
|
|
387
|
+
const cases = loadGolden(cwd);
|
|
388
|
+
if (cases.length === 0) {
|
|
389
|
+
console.log("골든셋 비어 있음 — replay할 케이스 없음. 'gbc gate snapshot on' 후 편집을 캡처하세요.");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const si = args.indexOf("--samples");
|
|
393
|
+
let samples = si >= 0 ? Math.max(1, Number.parseInt(args[si + 1] ?? "1", 10) || 1) : 1;
|
|
394
|
+
// 모달 판정은 동수(tie)면 pass로 떨어져 block-기대 케이스의 드리프트를 *놓친다* → 짝수면 홀수로
|
|
395
|
+
// 올려 tie를 원천 제거(다수결은 본래 홀수 표본을 요구). 기본 1은 홀수라 무영향.
|
|
396
|
+
if (samples % 2 === 0) {
|
|
397
|
+
console.log(` (--samples ${samples}=짝수 → 동수 방지 위해 ${samples + 1}로 조정)`);
|
|
398
|
+
samples += 1;
|
|
399
|
+
}
|
|
400
|
+
const { judge } = await import("./judge.js");
|
|
401
|
+
console.log(`🐢 골든셋 replay — ${cases.length}건${samples > 1 ? ` · ${samples}-sample 모달` : ""} (judge temp 0; CLI 폴백은 best-effort)`);
|
|
402
|
+
const outcomes = [];
|
|
403
|
+
for (const c of cases) {
|
|
404
|
+
// N-sample 모달 판정 — temp 0도 bit-stable 아니라, 다수결로 잔여 비결정을 흡수한다.
|
|
405
|
+
const votes = { pass: 0, block: 0 };
|
|
406
|
+
let lastMissing = [];
|
|
407
|
+
for (let i = 0; i < samples; i++) {
|
|
408
|
+
const v = await judge(c.spec, c.edit, c.defers, { temperature: 0 });
|
|
409
|
+
votes[v.verdict]++;
|
|
410
|
+
lastMissing = v.missing;
|
|
411
|
+
}
|
|
412
|
+
const actual = votes.block > votes.pass ? "block" : "pass";
|
|
413
|
+
const diff = diffVerdict(c.expected, { verdict: actual, missing: lastMissing });
|
|
414
|
+
outcomes.push({ id: c.id, tool: c.tool, expected: c.expected.verdict, actual, diff });
|
|
415
|
+
const mark = diff.decisionFlip ? "❌FLIP" : diff.missingChanged ? "·missing변화" : "✓";
|
|
416
|
+
console.log(` ${mark} ${c.tool} ${c.id}: ${c.expected.verdict}→${actual}`);
|
|
417
|
+
}
|
|
418
|
+
const s = summarizeReplay(outcomes);
|
|
419
|
+
console.log(`\n결과: 일치 ${s.matched}/${s.total} · 판정뒤집힘 ${s.flips} · (정보용 missing변화 ${s.missingOnly})`);
|
|
420
|
+
if (s.flips > 0) {
|
|
421
|
+
console.log(`❌ 드리프트 감지 — ${s.flips}건 판정 뒤집힘. 모델/프롬프트/SDK 변화로 게이트 판정이 바뀌었습니다.`);
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
console.log("✅ 드리프트 없음 — 캡처 시점과 동일 판정.");
|
|
425
|
+
}
|
|
426
|
+
/** `gbc gate review` 인자에서 --spec/--defer 뒤의 비-플래그 토큰을 각각 모아 ref 문자열로. */
|
|
427
|
+
function parseReviewArgs(args) {
|
|
428
|
+
let cur = null;
|
|
429
|
+
const spec = [];
|
|
430
|
+
const defer = [];
|
|
431
|
+
for (const a of args) {
|
|
432
|
+
if (a === "--spec") {
|
|
433
|
+
cur = "spec";
|
|
434
|
+
}
|
|
435
|
+
else if (a === "--defer") {
|
|
436
|
+
cur = "defer";
|
|
437
|
+
}
|
|
438
|
+
else if (a.startsWith("--")) {
|
|
439
|
+
cur = null; // 알 수 없는 플래그 — 수집 중단
|
|
440
|
+
}
|
|
441
|
+
else if (cur === "spec") {
|
|
442
|
+
spec.push(a);
|
|
443
|
+
}
|
|
444
|
+
else if (cur === "defer") {
|
|
445
|
+
defer.push(a);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { specRefs: spec.join(" "), deferRefs: defer.join(" ") };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 게이트 block이 도출한 펜딩 누락 케이스를 사람-승인 체크리스트로 일괄 분류한다(A1).
|
|
452
|
+
* - 인자 없음: 번호 체크리스트만 표시(검토 모드).
|
|
453
|
+
* - --spec/--defer refs: 승인→spec.md 등록 / 미룸→defer 등록(겹치면 spec 우선), 후 펜딩 비움.
|
|
454
|
+
*/
|
|
455
|
+
function cmdGateReview(cwd, args) {
|
|
456
|
+
const pending = readPendingReview(cwd);
|
|
457
|
+
if (!pending || pending.missing.length === 0) {
|
|
458
|
+
console.log("🐢 검토할 펜딩 누락 케이스가 없습니다. (게이트 block 시 도출된 누락 케이스가 여기 모입니다)");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const { specRefs, deferRefs } = parseReviewArgs(args);
|
|
462
|
+
// 분류 ref 없음 = 체크리스트만(검토 모드)
|
|
463
|
+
if (specRefs === "" && deferRefs === "") {
|
|
464
|
+
console.log(`🐢 펜딩 누락 케이스 ${pending.missing.length}건 (사유: ${pending.reason} · 소스: ${pending.source}):`);
|
|
465
|
+
pending.missing.forEach((c, i) => console.log(` ${i + 1}. ${c}`));
|
|
466
|
+
console.log(`→ 분류: gbc gate review --spec <번호|텍스트|all> --defer <번호|텍스트|all>\n` +
|
|
467
|
+
` (승인→spec.md / 미룸→defer. 겹치면 spec 우선. 분류 후 펜딩은 비워지고, 재편집 시 등록 기준으로 재판정)`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const { toSpec, toDefer } = resolveRefs(pending.missing, specRefs, deferRefs);
|
|
471
|
+
if (toSpec.length === 0 && toDefer.length === 0) {
|
|
472
|
+
console.error("ref가 어떤 펜딩 케이스에도 매칭되지 않았습니다 — 'gbc gate review'로 번호를 확인하세요.");
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
const beforeHash = curHash(cwd); // 변이 전 해시 = 게이트된 작업단위와 상관(M1 churn)
|
|
476
|
+
for (const c of toSpec) {
|
|
477
|
+
addSpecCase(cwd, c);
|
|
478
|
+
logCli(cwd, "spec-add", beforeHash);
|
|
479
|
+
}
|
|
480
|
+
for (const c of toDefer) {
|
|
481
|
+
addDefer(cwd, c);
|
|
482
|
+
logCli(cwd, "defer-add", beforeHash);
|
|
483
|
+
}
|
|
484
|
+
clearPendingReview(cwd);
|
|
485
|
+
if (toSpec.length > 0)
|
|
486
|
+
console.log(`🐢 명세 등록 ${toSpec.length}건: ${toSpec.join(", ")}`);
|
|
487
|
+
if (toDefer.length > 0)
|
|
488
|
+
console.log(`🐢 미룸 등록 ${toDefer.length}건: ${toDefer.join(", ")}`);
|
|
489
|
+
console.log("→ 검토 완료(펜딩 비움). 같은 편집을 재시도하면 등록된 케이스 기준으로 재판정됩니다.");
|
|
490
|
+
}
|
|
314
491
|
// ---------- gbc metrics ----------
|
|
315
492
|
function cmdMetrics(args) {
|
|
316
493
|
const cwd = process.cwd();
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
494
|
+
const all = args.includes("--all");
|
|
495
|
+
// --all: 등록된 각 repo의 events.jsonl을 repo경로로 태깅 후 병합(specHash 해시충돌 차단).
|
|
496
|
+
// M1 churn축은 specHash 단독키라, 태깅 없이 합치면 repo간 boilerplate spec 해시가 충돌해
|
|
497
|
+
// 한 repo의 통과 뒤 다른 repo의 변이가 churn으로 오집계된다(M2/M3는 session-UUID 키라 안전).
|
|
498
|
+
let events;
|
|
499
|
+
let scope;
|
|
500
|
+
let source;
|
|
501
|
+
if (all) {
|
|
502
|
+
const merged = [];
|
|
503
|
+
let included = 0;
|
|
504
|
+
let skipped = 0;
|
|
505
|
+
for (const repo of loadRepos()) {
|
|
506
|
+
const abs = resolve(repo);
|
|
507
|
+
try {
|
|
508
|
+
// 단일 lstatSync로 symlink 거부 — existsSync+lstatSync 분리는 TOCTOU 경합창을 연다(보안검토 W1).
|
|
509
|
+
// lstat은 링크를 따라가지 않아 symlink면 isDirectory()=false, 부재면 throw→catch로 skip.
|
|
510
|
+
if (!lstatSync(abs).isDirectory()) {
|
|
511
|
+
skipped++;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const p = join(abs, ".gbc", "events.jsonl");
|
|
515
|
+
if (!existsSync(p)) {
|
|
516
|
+
skipped++;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
merged.push(...tagEventsWithRepo(parseEvents(readFileSync(p, "utf8")), abs));
|
|
520
|
+
included++;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
skipped++; // repo별 읽기 실패는 조용히 skip(fail-silent)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
events = merged;
|
|
527
|
+
scope = `전체 ${included}개 repo 병합${skipped ? ` (${skipped}개 skip: 부재/이벤트없음)` : ""}`;
|
|
528
|
+
source = "등록 repo들의 .gbc/events.jsonl(repo 태깅 병합)";
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
const eventsPath = join(cwd, ".gbc", "events.jsonl");
|
|
532
|
+
events = parseEvents(existsSync(eventsPath) ? readFileSync(eventsPath, "utf8") : "");
|
|
533
|
+
scope = cwd;
|
|
534
|
+
source = ".gbc/events.jsonl";
|
|
535
|
+
}
|
|
536
|
+
const m = computeMetrics(events);
|
|
320
537
|
if (args.includes("--json")) {
|
|
321
538
|
console.log(JSON.stringify(m, null, 2));
|
|
322
539
|
return;
|
|
323
540
|
}
|
|
324
|
-
console.log(`🐢 거북이 게이트 계측 — ${
|
|
325
|
-
이벤트 총 ${m.totalEvents}건 (
|
|
541
|
+
console.log(`🐢 거북이 게이트 계측 — ${scope}
|
|
542
|
+
이벤트 총 ${m.totalEvents}건 (${source})
|
|
326
543
|
|
|
327
544
|
[M3] 재호출/iteration — 작업단위당 edit 반복
|
|
328
545
|
작업단위 ${m.m3.workUnits} · 총 edit ${m.m3.totalEdits} · 평균 ${m.m3.avgEditsPerUnit}/단위 · 최대 ${m.m3.maxEditsPerUnit} · 반복(>1)단위 ${m.m3.multiEditUnits}
|
|
@@ -400,12 +617,48 @@ function cmdRepos(args) {
|
|
|
400
617
|
return;
|
|
401
618
|
}
|
|
402
619
|
console.log(`📁 등록된 repo ${repos.length}개:`);
|
|
620
|
+
let anyStale = false;
|
|
403
621
|
for (const r of repos) {
|
|
404
|
-
|
|
405
|
-
|
|
622
|
+
// 단일 lstatSync로 부재/symlink를 한 번에 판정 — existsSync+lstatSync 분리는 TOCTOU 경합창을
|
|
623
|
+
// 연다(보안검토 W1). lstat은 링크를 안 따라가 symlink면 isDir=false; 부재/권한오류는 throw→
|
|
624
|
+
// exists=false. gated가 true여야만 loadDefers·readProjectSettings로 그 경로를 읽으므로, 이
|
|
625
|
+
// 가드가 두 외부 읽기를 함께 막는다(cmdMetrics --all·buildCrossRepoHint와 동일 표준).
|
|
626
|
+
let exists = false;
|
|
627
|
+
let isDir = false;
|
|
628
|
+
try {
|
|
629
|
+
isDir = lstatSync(r).isDirectory();
|
|
630
|
+
exists = true;
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
/* 부재/권한오류 → exists=false */
|
|
634
|
+
}
|
|
635
|
+
const gated = isDir && existsSync(join(r, ".gbc"));
|
|
406
636
|
const unresolved = gated ? loadDefers(r).filter((d) => d.status !== "resolved").length : 0;
|
|
407
|
-
const mark = !exists
|
|
408
|
-
|
|
637
|
+
const mark = !exists
|
|
638
|
+
? "✗부재"
|
|
639
|
+
: !isDir
|
|
640
|
+
? "⚠️심링크거부"
|
|
641
|
+
: !gated
|
|
642
|
+
? "·gbc없음"
|
|
643
|
+
: unresolved
|
|
644
|
+
? `●미해결${unresolved}`
|
|
645
|
+
: "○깨끗";
|
|
646
|
+
// 게이트 건강성(B1) — gbc 프로젝트면 hook 등록 상태로 '게이트 조용히 죽음/구식 코호트'를 표면화.
|
|
647
|
+
// (회사 repo 게이트 미작동 미스터리 차단). 명령 freshness는 cliPath 의존이라 크로스-repo 미검출.
|
|
648
|
+
let health = "";
|
|
649
|
+
if (gated) {
|
|
650
|
+
const h = assessRepoHealth(readProjectSettings(r), true);
|
|
651
|
+
const flags = [h.gateDead ? "⚠️게이트hook부재" : "", h.missingSession ? "⚠️SessionStart누락" : ""].filter(Boolean);
|
|
652
|
+
if (flags.length) {
|
|
653
|
+
health = " " + flags.join(" ");
|
|
654
|
+
anyStale = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
console.log(` [${mark}] ${r}${health}`);
|
|
658
|
+
}
|
|
659
|
+
if (anyStale) {
|
|
660
|
+
console.log("\n⚠️ 게이트 hook 부재/SessionStart 누락 repo는 해당 repo에서 'gbc init --yes' 재실행으로 복구하세요.");
|
|
661
|
+
console.log(" (크로스-repo는 hook *등록 여부*만 검사 — 명령 freshness[설치경로 의존]는 각 repo에서 'gbc status'로 확인)");
|
|
409
662
|
}
|
|
410
663
|
}
|
|
411
664
|
else {
|
|
@@ -416,7 +669,8 @@ function usage() {
|
|
|
416
669
|
console.log(`🐢 gbc — 거북이코드 구현-전 게이트
|
|
417
670
|
|
|
418
671
|
사용:
|
|
419
|
-
gbc init [--yes]
|
|
672
|
+
gbc init [--yes] [--no-register] 프로젝트에 hook + /gate · /gbc-mute 스킬 설치
|
|
673
|
+
(--no-register: 크로스-repo 레지스트리 자동등록 생략)
|
|
420
674
|
gbc update [--dry-run] 전역 최신 설치 + 현재 프로젝트 재init (한방 갱신)
|
|
421
675
|
gbc status 게이트 상태 + 로드된 명세 확인
|
|
422
676
|
gbc defer add "<케이스>" 케이스를 명시적으로 미루기 (→ open)
|
|
@@ -430,7 +684,14 @@ function usage() {
|
|
|
430
684
|
gbc spec show 등록된 케이스 목록
|
|
431
685
|
gbc spec clear 명세 비우기(작업단위 종료)
|
|
432
686
|
gbc gate reset 작업단위 게이트 리셋
|
|
433
|
-
gbc
|
|
687
|
+
gbc gate review block이 도출한 누락 케이스 체크리스트 보기
|
|
688
|
+
gbc gate review --spec <ref> --defer <ref>
|
|
689
|
+
누락 케이스 일괄 분류(승인→spec / 미룸→defer)
|
|
690
|
+
gbc gate snapshot <on|off|status|list|clear>
|
|
691
|
+
골든셋 캡처 토글·상태(판정 드리프트 회귀락, 로컬 전용)
|
|
692
|
+
gbc gate snapshot replay [--samples N]
|
|
693
|
+
골든 케이스 재판정(temp 0)·드리프트 시 exit 1
|
|
694
|
+
gbc metrics [--all] [--json] 계측 리포트(M1~M3, B-모드 관측 프록시; --all=등록 repo 병합)
|
|
434
695
|
gbc repos add [경로] 크로스-repo 레지스트리에 추가(생략 시 현재 폴더)
|
|
435
696
|
gbc repos list 등록된 repo + 미해결 defer 수
|
|
436
697
|
gbc repos remove [경로] 레지스트리에서 제거
|
package/dist/config.js
CHANGED
|
@@ -16,3 +16,13 @@ export function setStopHintMuted(cwd, muted) {
|
|
|
16
16
|
cfg.stopHintMuted = muted;
|
|
17
17
|
writeJson(configPath(cwd), cfg);
|
|
18
18
|
}
|
|
19
|
+
/** 골든셋 캡처 모드인지. 파일/키 부재 시 false(기본=캡처 안 함). */
|
|
20
|
+
export function isGoldenCapture(cwd) {
|
|
21
|
+
return readConfig(cwd).captureGolden === true;
|
|
22
|
+
}
|
|
23
|
+
/** 골든셋 캡처 모드 토글을 영속 저장(수동 off 전까지 유지). */
|
|
24
|
+
export function setGoldenCapture(cwd, on) {
|
|
25
|
+
const cfg = readConfig(cwd);
|
|
26
|
+
cfg.captureGolden = on;
|
|
27
|
+
writeJson(configPath(cwd), cfg);
|
|
28
|
+
}
|
package/dist/golden.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 골든셋 캡처 + 게이트 판정 드리프트 회귀락 (A2). 순수 코어 — 비교/해시/집계는 결정론이라
|
|
2
|
+
// 단위테스트 대상. judge 재실행(LLM·네트워크)·캡처 훅·CLI 출력은 비결정이라 여기 없음.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gbcDir, readJson, writeJson } from "./store.js";
|
|
6
|
+
/**
|
|
7
|
+
* tool+edit+spec의 안정 해시 — upsert 디둑 키. 널바이트 구분자로 필드 경계 모호성 차단
|
|
8
|
+
* (("a","bc") vs ("ab","c") 충돌 방지).
|
|
9
|
+
*/
|
|
10
|
+
export function goldenCaseId(tool, edit, spec) {
|
|
11
|
+
return createHash("sha256").update(`${tool}\x00${edit}\x00${spec}`).digest("hex").slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
/** 기대 vs 재판정 비교. decisionFlip=하드, missingChanged=정보용. */
|
|
14
|
+
export function diffVerdict(expected, actual) {
|
|
15
|
+
const decisionFlip = expected.verdict !== actual.verdict;
|
|
16
|
+
const norm = (xs) => [...new Set(xs)].sort();
|
|
17
|
+
const em = norm(expected.missing);
|
|
18
|
+
const am = norm(actual.missing);
|
|
19
|
+
const missingChanged = em.length !== am.length || em.some((v, i) => v !== am[i]);
|
|
20
|
+
return { decisionFlip, missingChanged, match: !decisionFlip && !missingChanged };
|
|
21
|
+
}
|
|
22
|
+
/** id 디둑 upsert — 같은 id면 교체(최신 expected), 없으면 추가. */
|
|
23
|
+
export function upsertGolden(cases, c) {
|
|
24
|
+
return [...cases.filter((x) => x.id !== c.id), c];
|
|
25
|
+
}
|
|
26
|
+
/** replay 결과 집계 — 플립/정보용변화/일치 카운트 + 플립 케이스 목록. */
|
|
27
|
+
export function summarizeReplay(outcomes) {
|
|
28
|
+
const flipped = outcomes.filter((o) => o.diff.decisionFlip);
|
|
29
|
+
const matched = outcomes.filter((o) => o.diff.match).length;
|
|
30
|
+
const missingOnly = outcomes.filter((o) => !o.diff.decisionFlip && o.diff.missingChanged).length;
|
|
31
|
+
return { total: outcomes.length, matched, flips: flipped.length, missingOnly, flipped };
|
|
32
|
+
}
|
|
33
|
+
// ---------- IO (ST-A2-2에서 구현) ----------
|
|
34
|
+
function goldenPath(cwd) {
|
|
35
|
+
return join(gbcDir(cwd), "golden.json");
|
|
36
|
+
}
|
|
37
|
+
export function loadGolden(cwd) {
|
|
38
|
+
return readJson(goldenPath(cwd), []);
|
|
39
|
+
}
|
|
40
|
+
export function addGoldenCase(cwd, c) {
|
|
41
|
+
writeJson(goldenPath(cwd), upsertGolden(loadGolden(cwd), c));
|
|
42
|
+
}
|
|
43
|
+
export function clearGolden(cwd) {
|
|
44
|
+
writeJson(goldenPath(cwd), []);
|
|
45
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -5,10 +5,12 @@ import { isGatedTool, normalizeEdit } from "./normalize.js";
|
|
|
5
5
|
import { loadPlanSpec, computeSpecHash } from "./spec.js";
|
|
6
6
|
import { isGated, markGated } from "./state.js";
|
|
7
7
|
import { activeDeferItems, loadDefers } from "./defer.js";
|
|
8
|
-
import { isStopHintMuted } from "./config.js";
|
|
8
|
+
import { isStopHintMuted, isGoldenCapture } from "./config.js";
|
|
9
9
|
import { loadRepos } from "./repos.js";
|
|
10
|
+
import { writePendingReview } from "./review.js";
|
|
11
|
+
import { addGoldenCase, goldenCaseId } from "./golden.js";
|
|
10
12
|
import { readProjectSettings, buildUpdateNotice, wasNotified, markNotified } from "./notice.js";
|
|
11
|
-
import { isCacheStale, readVersionCache, refreshVersionCache } from "./version.js";
|
|
13
|
+
import { isCacheStale, readVersionCache, refreshVersionCache, shouldRefreshCache, } from "./version.js";
|
|
12
14
|
import { appendFileSync, existsSync, lstatSync } from "node:fs";
|
|
13
15
|
import { join, resolve } from "node:path";
|
|
14
16
|
import { gbcDir } from "./store.js";
|
|
@@ -27,9 +29,11 @@ export function buildBlockReason(verdict, specEmpty, source) {
|
|
|
27
29
|
`사용자 승인 없이 자동 등록하지 마세요. (명세 소스: ${source})`);
|
|
28
30
|
}
|
|
29
31
|
const missingLine = verdict.missing.length > 0 ? `\n누락(침묵): ${verdict.missing.join(", ")}` : "";
|
|
32
|
+
// 누락 케이스는 .gbc/pending-review.json에 기록돼 있어 'gbc gate review'로 번호 체크리스트
|
|
33
|
+
// 일괄 분류(승인→spec / 미룸→defer)가 가능하다. 개별 처리(직접 구현·gbc defer add)도 유효.
|
|
30
34
|
return (`🐢 거북이 게이트 — ${verdict.reason}${missingLine}\n` +
|
|
31
|
-
`→
|
|
32
|
-
` (명세 소스: ${source})`);
|
|
35
|
+
`→ 누락 케이스를 'gbc gate review'로 한 번에 분류(승인→spec / 미룸→defer)하거나, 지금 이 변경에서 직접 다루세요.` +
|
|
36
|
+
` 개별로 미룰 거면 'gbc defer add "<케이스>"'. (명세 소스: ${source})`);
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* pass verdict를 작업단위 캐시(markGated)에 넣어도 되는가.
|
|
@@ -142,13 +146,46 @@ export async function runPreToolUse(ctx) {
|
|
|
142
146
|
tool: toolName,
|
|
143
147
|
decision: "cached",
|
|
144
148
|
});
|
|
149
|
+
// 업데이트 안내(있으면)를 cached-skip에서도 노출 — 평상 작업은 대부분 통과된 작업단위라
|
|
150
|
+
// 이 경로가 가장 흔하다. 여기서 빠지면 보이는 배너(PreToolUse systemMessage)가 거의 안 떴음
|
|
151
|
+
// (0.2.x 가시성 갭). maybeUpdateNotice는 세션당 1회 dedup이라 노이즈 없음(매 세션 첫 편집 1회).
|
|
152
|
+
// permissionDecision 없음 → cached-pass 통과 동작 불변. 네트워크 없음(캐시만 읽음).
|
|
153
|
+
const cachedNotice = maybeUpdateNotice(cwd, session, ctx);
|
|
154
|
+
if (cachedNotice)
|
|
155
|
+
emit({ systemMessage: cachedNotice });
|
|
145
156
|
process.exit(0);
|
|
146
157
|
}
|
|
147
158
|
// judge는 여기서만 동적 import (SDK lazy)
|
|
148
159
|
const { judge } = await import("./judge.js");
|
|
149
160
|
const editText = normalizeEdit(toolName, input.tool_input ?? {});
|
|
150
161
|
const defers = activeDeferItems(cwd);
|
|
162
|
+
// ①신버전 캐시 자동 refresh(0.3.0) — 사용자가 'gbc status'를 안 쳐도 캐시가 최신이 되게.
|
|
163
|
+
// judge(네트워크·≥1.5s)와 *병렬*로만 건다 → 핫패스 지연 0. cache-miss(여기 = judge 도는
|
|
164
|
+
// 비-핫패스)에서만 stale일 때. cached-skip 핫패스엔 절대 네트워크 안 넣는다(0.2.7 원칙 보존).
|
|
165
|
+
// refreshVersionCache는 내부 fail-silent(reject 불가)라 judge 경로를 깨지 않는다.
|
|
166
|
+
const refreshP = shouldRefreshCache(Boolean(ctx?.cliPath)) ? refreshVersionCache() : null;
|
|
151
167
|
const verdict = await judge(specText, editText, defers);
|
|
168
|
+
if (refreshP)
|
|
169
|
+
await refreshP; // judge 동안 이미 완료 — 이 편집의 notice가 갱신된 캐시를 읽도록
|
|
170
|
+
// 골든셋 캡처(A2, opt-in) — judge가 실제 평가한 cache-miss edit만, fail-open 제외(실판정 아님).
|
|
171
|
+
// editText(편집 본문)는 events.jsonl이 절대 저장 안 하는 내용 → 캡처는 .gbc/golden.json에 로컬만.
|
|
172
|
+
// 캡처 실패는 게이트를 막지 않는다(fail-silent). cached-skip 경로는 위에서 이미 return돼 도달 안 함.
|
|
173
|
+
if (!verdict.failOpen && isGoldenCapture(cwd)) {
|
|
174
|
+
try {
|
|
175
|
+
addGoldenCase(cwd, {
|
|
176
|
+
id: goldenCaseId(toolName, editText, specText),
|
|
177
|
+
at: nowIso(),
|
|
178
|
+
tool: toolName,
|
|
179
|
+
edit: editText,
|
|
180
|
+
spec: specText,
|
|
181
|
+
defers,
|
|
182
|
+
expected: { verdict: verdict.verdict, missing: verdict.missing, reason: verdict.reason },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* 캡처 실패는 무시(fail-silent) */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
152
189
|
if (verdict.verdict === "pass") {
|
|
153
190
|
// fail-open(판정 실패) 먼저 분기 — 빈-spec 정상 pass가 fail-open으로 오분류되지 않게.
|
|
154
191
|
if (verdict.failOpen) {
|
|
@@ -195,6 +232,22 @@ export async function runPreToolUse(ctx) {
|
|
|
195
232
|
// block: 사람 pause (ask 기본) — 사유가 사용자에게 표시됨
|
|
196
233
|
// 시나리오 미지정(명세 빈약)과 침묵 누락을 다르게 안내한다.
|
|
197
234
|
const reason = buildBlockReason(verdict, specText.trim() === "", source);
|
|
235
|
+
// 침묵-누락 케이스(missing[])를 펜딩-검토에 기록 → 'gbc gate review'가 번호 체크리스트로 회수.
|
|
236
|
+
// judge의 missing[]가 buildBlockReason prose로만 평탄화돼 사라지던 seam 보존(A1). missing 없으면
|
|
237
|
+
// (시나리오 미지정 등) 기록 안 함 = 검토할 케이스 없음. 쓰기 실패는 게이트를 깨지 않는다(fail-silent).
|
|
238
|
+
if (verdict.missing.length > 0) {
|
|
239
|
+
try {
|
|
240
|
+
writePendingReview(cwd, {
|
|
241
|
+
missing: verdict.missing,
|
|
242
|
+
reason: verdict.reason,
|
|
243
|
+
source,
|
|
244
|
+
at: nowIso(),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* 펜딩 기록 실패는 무시 — 안내(reason)는 이미 미룸/직접처리 경로를 담고 있다 */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
198
251
|
logEvent(cwd, {
|
|
199
252
|
at: nowIso(),
|
|
200
253
|
session,
|
package/dist/install.js
CHANGED
|
@@ -79,6 +79,34 @@ export function hasSessionStartHook(settings) {
|
|
|
79
79
|
}
|
|
80
80
|
return false;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* (read-only) PreToolUse 게이트 hook('hook pre-tool-use' 명령)이 등록돼 있는지 — cliPath 무관.
|
|
84
|
+
* hasStalePreToolUse가 *명령 freshness*(cliPath 의존)를 보는 반면, 이건 *존재 자체*만 본다.
|
|
85
|
+
* 크로스-repo 건강성 판정에 쓴다: 타 repo의 정식 cliPath를 알 수 없으므로(각 설치경로 상이) freshness는
|
|
86
|
+
* 검사 불가지만, '게이트 hook이 아예 없음'(=게이트 조용히 죽음)은 cliPath 없이도 결정론적으로 잡힌다.
|
|
87
|
+
*/
|
|
88
|
+
export function hasPreToolUseGate(settings) {
|
|
89
|
+
for (const entry of settings.hooks?.PreToolUse ?? []) {
|
|
90
|
+
for (const h of entry.hooks ?? []) {
|
|
91
|
+
if (h.command.includes("hook pre-tool-use"))
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 크로스-repo 게이트 건강성을 settings로 판정(cliPath 무관·결정론적). isGbcProject=false(.gbc 없음)면
|
|
99
|
+
* 게이트 대상이 아니라 둘 다 false. 명령 freshness(stale)는 *의도적으로* 검사하지 않는다 — 각 repo
|
|
100
|
+
* 설치경로가 달라 현재 런타임 cliPath로 타 repo를 stale 판정하면 false-positive가 된다(B1 트림 결정).
|
|
101
|
+
*/
|
|
102
|
+
export function assessRepoHealth(settings, isGbcProject) {
|
|
103
|
+
if (!isGbcProject)
|
|
104
|
+
return { gateDead: false, missingSession: false };
|
|
105
|
+
return {
|
|
106
|
+
gateDead: !hasPreToolUseGate(settings),
|
|
107
|
+
missingSession: !hasSessionStartHook(settings),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
82
110
|
/**
|
|
83
111
|
* SessionStart hook을 멱등 등록한다. matcher "startup|resume"로 신규 진입·재개에만 발화
|
|
84
112
|
* (compact마다 반복 노이즈 방지). 이미 'hook session-start' 명령이 있으면 추가하지 않는다.
|
package/dist/judge.js
CHANGED
|
@@ -90,7 +90,7 @@ export function selectedTransport() {
|
|
|
90
90
|
return resolveApiKey() ? "api" : "cli";
|
|
91
91
|
}
|
|
92
92
|
/** 직접 Anthropic API (haiku). SDK는 여기서만 lazy import → hook 핫패스 보호. */
|
|
93
|
-
async function judgeViaApi(system, user) {
|
|
93
|
+
async function judgeViaApi(system, user, temperature) {
|
|
94
94
|
const mod = await import("@anthropic-ai/sdk");
|
|
95
95
|
const Anthropic = mod.default;
|
|
96
96
|
// 키를 코드에서 해석(env 또는 ~/.gbc/api-key)해 명시 전달 — 셸 주입 불필요(크로스플랫폼).
|
|
@@ -98,6 +98,9 @@ async function judgeViaApi(system, user) {
|
|
|
98
98
|
const resp = await client.messages.create({
|
|
99
99
|
model: MODEL,
|
|
100
100
|
max_tokens: 1024,
|
|
101
|
+
// temperature는 replay(회귀락)에서만 0으로 핀해 결정성을 높인다. 핫패스는 undefined=API 기본
|
|
102
|
+
// (기존 판정 분포 보존). undefined면 키 자체를 안 보내 SDK 기본을 쓴다.
|
|
103
|
+
...(temperature !== undefined ? { temperature } : {}),
|
|
101
104
|
system,
|
|
102
105
|
messages: [{ role: "user", content: user }],
|
|
103
106
|
});
|
|
@@ -171,12 +174,13 @@ function judgeViaCliWin(system, user) {
|
|
|
171
174
|
* 게이트 판정. ANTHROPIC_API_KEY 있으면 직접 API(빠름), 없으면 claude -p 폴백.
|
|
172
175
|
* 실패 시 안전하게 pass(fail-open) — 게이트가 개발을 막아버리는 사고 방지.
|
|
173
176
|
*/
|
|
174
|
-
export async function judge(planSpec, editText, defers = []) {
|
|
177
|
+
export async function judge(planSpec, editText, defers = [], opts = {}) {
|
|
175
178
|
const user = buildUserMessage(planSpec, editText, defers);
|
|
176
179
|
const transport = selectedTransport();
|
|
177
180
|
try {
|
|
181
|
+
// claude -p 폴백은 temperature 플래그가 없어 핀 불가 → CLI-transport replay는 best-effort.
|
|
178
182
|
const raw = transport === "api"
|
|
179
|
-
? await judgeViaApi(GATE_SYSTEM, user)
|
|
183
|
+
? await judgeViaApi(GATE_SYSTEM, user, opts.temperature)
|
|
180
184
|
: await judgeViaCli(GATE_SYSTEM, user);
|
|
181
185
|
return parseVerdict(raw);
|
|
182
186
|
}
|
package/dist/metrics.js
CHANGED
|
@@ -52,6 +52,15 @@ export function parseEvents(raw) {
|
|
|
52
52
|
}
|
|
53
53
|
return out;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* 크로스-repo 집계용 — 비어있지 않은 specHash만 'repoTag::specHash'로 태깅한다. 빈 specHash("")는
|
|
57
|
+
* 센티넬(작업단위 식별 불가)이라 그대로 둬 computeMetrics의 교차세션 제외 가드를 유지한다. repo간
|
|
58
|
+
* 동일/boilerplate spec 해시가 firstPassAt·groupKey(session 없는 CLI 이벤트)에서 충돌해 churn을
|
|
59
|
+
* 교차오염시키는 것을 막는다(session-UUID 키인 M2/M3 hook 이벤트는 원래 안전).
|
|
60
|
+
*/
|
|
61
|
+
export function tagEventsWithRepo(events, repoTag) {
|
|
62
|
+
return events.map((e) => (e.specHash ? { ...e, specHash: `${repoTag}::${e.specHash}` } : e));
|
|
63
|
+
}
|
|
55
64
|
const M1_NOTE = "B-모드 약신호(churn proxy) — 진짜 M1(post-gate 시나리오 위반율)은 A-mode 사후대조 필요. " +
|
|
56
65
|
"spec.md 비었을 때(specHash='')는 작업단위 식별 불가라 churn 집계에서 제외(교차세션 합산 방지).";
|
|
57
66
|
/** 그룹핑 키: session 우선, 없으면 specHash(CLI 이벤트 상관) */
|
package/dist/repos.js
CHANGED
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
// 글로벌(~/.gbc/repos.json)에 저장한다 — 크로스프로젝트라 project .gbc/가 아니라 홈.
|
|
3
3
|
// (~/.gbc/api-key·~/.gbc/version-check.json과 동위. gbcDir(homedir())가 ~/.gbc를 보장.)
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
6
6
|
import { gbcDir, readJson, writeJson } from "./store.js";
|
|
7
7
|
function reposPath() {
|
|
8
8
|
return join(gbcDir(homedir()), "repos.json");
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* 등록된 repo 절대경로 목록(없으면 []). repos.json은 다른 프로세스가 수정할 수 있는 글로벌 파일이라
|
|
12
|
+
* 내용을 무조건 신뢰하지 않는다 — 비-문자열·비-절대경로 항목은 방어 필터한다(보안검토 W4). 절대경로만
|
|
13
|
+
* 통과시켜, 깨진/악의적 항목이 cmdMetrics --all 등의 읽기 대상이 되는 걸 차단한다(symlink 가드와 다층).
|
|
14
|
+
*/
|
|
11
15
|
export function loadRepos() {
|
|
12
|
-
|
|
16
|
+
const raw = readJson(reposPath(), []);
|
|
17
|
+
if (!Array.isArray(raw))
|
|
18
|
+
return [];
|
|
19
|
+
return raw.filter((r) => typeof r === "string" && isAbsolute(r));
|
|
13
20
|
}
|
|
14
21
|
/** repo 등록(절대경로 정규화·멱등 dedup). 반환=등록 후 전체 목록. */
|
|
15
22
|
export function addRepo(path) {
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 펜딩-검토 레이어 — 게이트 block이 도출한 침묵-누락 케이스(missing[])를 사람-승인 체크리스트로
|
|
2
|
+
// 회수한다. judge의 {verdict, missing[]}가 buildBlockReason prose 평탄화로 버려지던 seam을 구조 보존:
|
|
3
|
+
// block 시 hook이 missing[]를 .gbc/pending-review.json에 기록 → `gbc gate review`가 번호 체크리스트로
|
|
4
|
+
// 제시 → 사용자 분류(--spec refs / --defer refs)를 일괄 addSpecCase/addDefer로 적용 후 clear.
|
|
5
|
+
import { existsSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { gbcDir, readJson, writeJson } from "./store.js";
|
|
8
|
+
function pendingPath(cwd) {
|
|
9
|
+
return join(gbcDir(cwd), "pending-review.json");
|
|
10
|
+
}
|
|
11
|
+
/** 펜딩-검토 레코드 기록(block 시점). 기존 펜딩을 덮어쓴다(가장 최근 block만 유효). */
|
|
12
|
+
export function writePendingReview(cwd, p) {
|
|
13
|
+
writeJson(pendingPath(cwd), p);
|
|
14
|
+
}
|
|
15
|
+
/** 펜딩-검토 레코드 읽기. 없으면 null. */
|
|
16
|
+
export function readPendingReview(cwd) {
|
|
17
|
+
return readJson(pendingPath(cwd), null);
|
|
18
|
+
}
|
|
19
|
+
/** 펜딩-검토 레코드 제거(분류 완료 후). 파일 부재면 무동작(idempotent). */
|
|
20
|
+
export function clearPendingReview(cwd) {
|
|
21
|
+
const path = pendingPath(cwd);
|
|
22
|
+
if (existsSync(path))
|
|
23
|
+
rmSync(path);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 펜딩 케이스(1-base 표시) 중 ref에 해당하는 케이스 텍스트를 고른다. defer selectTargets와 동형:
|
|
27
|
+
* - "all" → 전부
|
|
28
|
+
* - 공백구분 토큰이 전부 정수 → 복수 인덱스(1-base, 범위 밖·중복 무시)
|
|
29
|
+
* - 그 외 → 부분 텍스트 1건 매칭(공백 포함 문구 하위호환)
|
|
30
|
+
* 빈 ref → [](includes("") 오매칭 방어).
|
|
31
|
+
*/
|
|
32
|
+
export function selectCases(cases, ref) {
|
|
33
|
+
const trimmed = ref.trim();
|
|
34
|
+
if (trimmed === "")
|
|
35
|
+
return [];
|
|
36
|
+
if (trimmed === "all")
|
|
37
|
+
return [...cases];
|
|
38
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
39
|
+
const allInts = tokens.length > 0 && tokens.every((t) => /^\d+$/.test(t));
|
|
40
|
+
if (allInts) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const t of tokens) {
|
|
43
|
+
const idx = Number.parseInt(t, 10);
|
|
44
|
+
if (idx >= 1 && idx <= cases.length && !out.includes(cases[idx - 1])) {
|
|
45
|
+
out.push(cases[idx - 1]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const found = cases.find((c) => c.includes(trimmed));
|
|
51
|
+
return found ? [found] : [];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 펜딩 케이스를 spec-추가 / defer-등록으로 분류한다. specRefs·deferRefs는 각각 selectCases ref.
|
|
55
|
+
* 한 케이스가 양쪽에 걸리면 **spec 우선**(승인이 미룸을 이긴다) — toDefer에서 toSpec 항목을 제외해
|
|
56
|
+
* 같은 케이스가 spec.md와 defers.json에 이중 등록되는 것을 막는다.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveRefs(missing, specRefs, deferRefs) {
|
|
59
|
+
const toSpec = selectCases(missing, specRefs);
|
|
60
|
+
const toDefer = selectCases(missing, deferRefs).filter((c) => !toSpec.includes(c));
|
|
61
|
+
return { toSpec, toDefer };
|
|
62
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -69,6 +69,18 @@ export function buildVersionNotice(current, cache) {
|
|
|
69
69
|
return (`🐢 거북이코드 신버전 ${cache.latest} 사용 가능(현재 ${current}). ` +
|
|
70
70
|
`갱신: 'gbc update'(전역 최신 + 현재 프로젝트 재init) 또는 수동 'npm i -g geobuke-code@latest → gbc init --yes'`);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* 캐시 자동 refresh를 해야 하는지(순수 술어). cliPath 없으면(직접 hook 호출) X, 안내 opt-out X,
|
|
74
|
+
* 캐시가 stale일 때만 true. PreToolUse cache-miss(judge 경로)에서 judge와 병렬 refresh를 거는
|
|
75
|
+
* 게이트 — 핫패스(cached-skip)는 절대 호출 안 함. env·파일 의존이라 테스트는 home/now 주입.
|
|
76
|
+
*/
|
|
77
|
+
export function shouldRefreshCache(hasCliPath, home, now = Date.now()) {
|
|
78
|
+
if (!hasCliPath)
|
|
79
|
+
return false;
|
|
80
|
+
if (process.env.GBC_NO_UPDATE_NOTICE === "1")
|
|
81
|
+
return false;
|
|
82
|
+
return isCacheStale(readVersionCache(home), now);
|
|
83
|
+
}
|
|
72
84
|
/**
|
|
73
85
|
* npm 레지스트리에서 최신 버전을 받아 캐시에 쓴다(짧은 타임아웃, 비차단·fail-silent).
|
|
74
86
|
* spawn(npm) 대신 fetch — Windows .cmd 실행 문제를 피한다. 실패·타임아웃은 조용히 무시.
|
package/package.json
CHANGED
package/skills/gate/SKILL.md
CHANGED
|
@@ -46,12 +46,19 @@ defer 항목은 **open(미착수) → in_progress(진행중) → resolved(해결
|
|
|
46
46
|
| 등록된 케이스 목록 | `gbc spec show` |
|
|
47
47
|
| 명세 비우기(작업단위 종료) | `gbc spec clear` |
|
|
48
48
|
| 작업단위 게이트 리셋(다음 편집에서 재발동) | `gbc gate reset` |
|
|
49
|
+
| block이 도출한 누락 케이스 체크리스트 보기 | `gbc gate review` |
|
|
50
|
+
| 누락 케이스 일괄 분류(승인→spec / 미룸→defer) | `gbc gate review --spec <번호\|텍스트\|all> --defer <번호\|텍스트\|all>` |
|
|
51
|
+
| 판정 골든셋 캡처 토글·조회 | `gbc gate snapshot <on\|off\|status\|list\|clear>` |
|
|
52
|
+
| 골든 케이스 재판정·드리프트 점검(temp 0, 뒤집힘 시 exit 1) | `gbc gate snapshot replay [--samples N]` |
|
|
49
53
|
|
|
50
54
|
## 사용 흐름
|
|
51
55
|
|
|
52
|
-
1. **게이트가
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
1. **게이트가 침묵 누락으로 차단했을 때**: hook이 사유와 누락 케이스를 알려주고, 그 케이스들은 `.gbc/pending-review.json`에 기록된다. 케이스가 여러 개면 하나씩 `gbc spec add`/`gbc defer add`를 반복하지 말고 **체크리스트로 일괄 분류**한다:
|
|
57
|
+
1. `gbc gate review` — 도출된 누락 케이스를 번호 목록으로 본다.
|
|
58
|
+
2. 사용자에게 제시·검증받는다(승인할 케이스 / 미룰 케이스 구분).
|
|
59
|
+
3. `gbc gate review --spec <승인 번호들> --defer <미룸 번호들>` — 한 번에 승인은 spec.md 등록, 미룸은 defer 등록(겹치면 spec 우선). 펜딩은 비워진다.
|
|
60
|
+
4. 재시도하면 등록 기준으로 재판정 → 통과.
|
|
61
|
+
- 단건이면 종전대로 (a) 지금 이 변경에서 직접 다루거나 (b) `gbc defer add "케이스"`로 미뤄도 된다. (절대 주석으로만 미루지 말 것)
|
|
55
62
|
2. **시나리오 미지정으로 차단됐을 때 — 에이전트 도출 루프**: 사용자가 명세를 수기로 쓰지 않는다. 에이전트가 다음을 수행한다:
|
|
56
63
|
1. 사용자 요청에서 의도·동작 시나리오와 형제 케이스를 **도출**한다.
|
|
57
64
|
2. 도출한 케이스를 사용자에게 **제시하고 검증받는다** — **사용자 승인 없이 자동 등록·구현 금지**(같은 에이전트가 도출+구현하면 고무도장이 됨).
|
|
@@ -73,4 +80,6 @@ defer 항목은 **open(미착수) → in_progress(진행중) → resolved(해결
|
|
|
73
80
|
|
|
74
81
|
- **주석 defer는 defer가 아니다.** `// 비밀번호 검증은 다음에` 같은 코드 주석은 게이트가 침묵 누락으로 본다. 반드시 `gbc defer add`로 레지스트리에 등록해야 한다.
|
|
75
82
|
- **게이트는 작업단위당 1회만 발동한다.** 명세가 바뀌거나 명세 밖 파일을 편집할 때 재발동한다. 강제로 다시 점검하려면 `gbc gate reset`.
|
|
83
|
+
- **게이트가 한 repo에서 아예 안 먹는다면** hook이 미등록·구식일 수 있다. `gbc repos list`가 등록된 각 repo의 게이트 건강성(`⚠️게이트hook부재`/`⚠️SessionStart누락`)을 표시한다 — 떴으면 그 repo에서 `gbc init --yes` 재실행. (크로스-repo는 hook 등록 여부만 검사하고 명령 freshness는 각 repo `gbc status`로 확인.)
|
|
76
84
|
- **`--no-gate` / `GBC_NO_GATE=1` 우회는 계측된다.** 우회 자체가 게이트 가치 측정 데이터가 된다.
|
|
85
|
+
- **판정 드리프트가 의심되면**(모델/gbc 업그레이드 후 게이트가 전과 다르게 군다) `gbc gate snapshot on`으로 한동안 캡처하고, 나중에 `gbc gate snapshot replay`로 재판정해 pass↔block 뒤집힘을 점검한다. 골든셋은 **편집 본문을 로컬 `.gbc/golden.json`에만** 저장한다(gitignore·로컬 pre-flight 전용 — 커밋하면 privacy 불변식 위반).
|