geobuke-code 0.1.0 → 0.2.1

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 CHANGED
@@ -21,7 +21,17 @@
21
21
 
22
22
  ## 설치
23
23
 
24
- > ⚠️ **현재 npm 미발행** — 아래 **로컬 개발 설치**가 유일한 경로다. (`npm install -g geobuke-code` 공개배포는 후속 A(public) 단계.)
24
+ ```bash
25
+ # 1) 전역 설치
26
+ npm install -g geobuke-code
27
+
28
+ # 2) 대상 프로젝트에 게이트 설치
29
+ cd <your-project>
30
+ gbc init # .claude/settings.json에 hook + /gate skill 머지 (동의·백업)
31
+ ```
32
+
33
+ <details>
34
+ <summary>로컬 개발 설치 (소스에서 빌드)</summary>
25
35
 
26
36
  ```bash
27
37
  # 1) 클론 + 빌드 (dist/ 생성)
@@ -36,32 +46,37 @@ printf '#!/bin/sh\nexec node "%s/dist/cli.js" "$@"\n' "$PWD" > ~/.local/bin/gbc
36
46
 
37
47
  # 3) 대상 프로젝트에 게이트 설치
38
48
  cd <your-project>
39
- gbc init # .claude/settings.json에 hook + /gate skill 머지 (동의·백업)
49
+ gbc init
40
50
  ```
41
51
 
42
- `gbc init`은 **프로젝트 로컬 `.claude/settings.json`만** 머지한다(append·멱등·백업). 전역 `~/.claude`는 건드리지 않는다.
43
-
44
52
  소스를 수정하면 `npm run build`만 다시 하면 된다 — wrapper/link는 같은 `dist/cli.js`를 가리키므로 재연결 불필요.
45
53
 
54
+ </details>
55
+
56
+ `gbc init`은 **프로젝트 로컬 `.claude/settings.json`만** 머지한다(append·멱등·백업). 전역 `~/.claude`는 건드리지 않는다. hook 명령은 **셸 무관 순수 형태**(`node "<path>" hook pre-tool-use`)라 Windows(cmd.exe)/bash/zsh/Mac에서 동일하게 동작한다.
57
+
46
58
  ## 빠른 게이트 활성화 (API 키 — 선택)
47
59
 
48
- `gbc init`은 키 주입 **없는** hook을 설치한다 → 기본은 `claude -p` 폴백(~13–20s). **haiku 직접 API(~1–3s)**를 쓰려면 키를 **hook 명령에만** 주입한다.
60
+ 키가 없으면 `claude -p` 폴백(~13–20s)으로 무설정 동작한다. **haiku 직접 API(~1–3s)**를 쓰려면 **키 파일만 만들면 된다** — gbc가 실행 시 직접 읽으므로 settings.json 수정이나 셸 주입은 불필요하다(0.2.1+).
61
+
62
+ > ⚠️ **native Windows에선 API 키가 사실상 필수다.** `claude -p` 폴백은 `claude.cmd`(배치 shim)를 셸 없이 실행하지 못해(ENOENT) fail-open으로 빠질 수 있다. Windows에선 위 키 파일을 만들어 API 경로로 쓰는 것을 권장한다(WSL/Mac/Linux는 폴백 정상).
49
63
 
50
- > ⚠️ **`export ANTHROPIC_API_KEY=…` 전역 설정 금지.** Claude Code 본체가 그 키로 **과금 전환**된다(구독 대신 키 과금). 게이트 hook 서브프로세스에만 주입해야 안전하다.
64
+ 해석 순서: `ANTHROPIC_API_KEY` 환경변수 > `~/.gbc/api-key` 파일.
51
65
 
52
66
  ```bash
53
- # 1) 키를 파일에 저장 (권한 600)
67
+ # bash / zsh / WSL / Mac
54
68
  mkdir -p ~/.gbc && printf '%s' 'sk-ant-...' > ~/.gbc/api-key && chmod 600 ~/.gbc/api-key
55
69
  ```
56
70
 
57
- ```jsonc
58
- // 2) 대상 프로젝트 .claude/settings.json의 PreToolUse command 앞에 주입을 추가:
59
- // "command": "node \"…/dist/cli.js\" hook pre-tool-use"
60
- //
61
- "command": "ANTHROPIC_API_KEY=\"$(cat ~/.gbc/api-key)\" node \"…/dist/cli.js\" hook pre-tool-use"
71
+ ```powershell
72
+ # native Windows (PowerShell) -NoNewline 필수(끝에 개행 붙으면오염)
73
+ New-Item -ItemType Directory -Force -Path "$HOME\.gbc" | Out-Null
74
+ Set-Content -Path "$HOME\.gbc\api-key" -Value "sk-ant-..." -NoNewline
62
75
  ```
63
76
 
64
- `gbc status`엔 `트랜스포트: cli`로 보일 있다(status 명령 자체엔 키가 없어서). 무관하다 실제 hook 발동 시엔 주입으로 `api(haiku)` 경로로 동작한다.
77
+ > ⚠️ **`export ANTHROPIC_API_KEY=…` 전역 설정(또는 settings.json top-level `env`) 금지.** Claude Code 본체가 키로 **과금 전환**된다(구독 대신 과금). 파일 방식은 gbc 판정 호출에만 키가 쓰이므로 함정을 구조적으로 피한다.
78
+
79
+ `gbc status`는 키 파일/환경변수를 반영해 `트랜스포트: api`로 표시한다(0.2.1+).
65
80
 
66
81
  ## 동작 원리
67
82
 
@@ -91,12 +106,12 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
91
106
 
92
107
  | 조건 | 트랜스포트 | 지연 |
93
108
  |---|---|---|
94
- | `ANTHROPIC_API_KEY` 설정됨 | Anthropic API 직접 (haiku, 최소 시스템프롬프트) | ~1–3s (목표) |
95
- | 미설정 | `claude -p` 폴백 (CC 인증 재사용, 무설정) | ~13–20s |
109
+ | 키 있음 (`ANTHROPIC_API_KEY` env 또는 `~/.gbc/api-key` 파일) | Anthropic API 직접 (haiku, 최소 시스템프롬프트) | ~1–3s (목표) |
110
+ | 없음 | `claude -p` 폴백 (CC 인증 재사용, 무설정 / ⚠️native Windows 미지원) | ~13–20s |
96
111
 
97
112
  **작업단위 1회**: 게이트는 작업단위(계획 명세 해시)당 한 번만 발동한다. 명세가 바뀌거나 명세 밖 파일을 편집할 때만 재발동 → 매 편집 지연을 피한다.
98
113
 
99
- > 빠른 게이트를 원하면 `ANTHROPIC_API_KEY`를 설정하라(설정법·과금 주의: 위 [「빠른 게이트 활성화」](#빠른-게이트-활성화-api-키--선택)). 없으면 `claude -p` 폴백으로 무설정 동작하되 작업단위당 한 번 느리다.
114
+ > 빠른 게이트를 원하면 `~/.gbc/api-key` 키 파일을 만들어라(설정법·과금 주의: 위 [「빠른 게이트 활성화」](#빠른-게이트-활성화-api-키--선택)). 없으면 `claude -p` 폴백으로 무설정 동작하되 작업단위당 한 번 느리다.
100
115
 
101
116
  ## 명령
102
117
 
@@ -111,16 +126,29 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
111
126
  | `gbc spec show` | 등록된 케이스 목록 |
112
127
  | `gbc spec clear` | 명세 비우기(작업단위 종료) |
113
128
  | `gbc gate reset` | 작업단위 게이트 리셋 |
129
+ | `gbc metrics [--json]` | 계측 리포트(M1~M3) |
114
130
 
115
131
  우회: `GBC_NO_GATE=1` (계측됨 — 우회 자체가 게이트 가치 측정 데이터).
116
132
 
133
+ ## 계측 (M1~M3)
134
+
135
+ 게이트는 모든 결정을 `.gbc/events.jsonl`(append-only, 메타데이터만 — 코드 본문 미기록)에 기록한다. `gbc metrics`로 집계를 본다. 끄려면 `GBC_NO_METRICS=1`.
136
+
137
+ | 지표 | 관측 | B-모드 신뢰도 |
138
+ |---|---|---|
139
+ | **M2** 게이트 적중 vs 도중발견 | 차단이 잡은 누락 케이스 수 vs `defer add`로 도중 등록된 수 | **강** (defer-registry와 1:1) |
140
+ | **M3** 재호출/iteration | 작업단위당 편집 반복 횟수 | proxy |
141
+ | **M1** post-gate 재작업 | 통과 후 churn(spec 변경·gate reset·defer) | **약** (churn proxy) |
142
+
143
+ > ⚠️ **진짜 M1**(통과 후 시나리오 위반율)은 게이트가 엔진 출력을 채점하는 **사후 대조**가 필요하다 — 이는 후속 A(standalone) 모드 영역이다. B-커널(hook)은 churn 약신호만 관측한다. `events.jsonl` 원시 로그는 그때 그대로 재사용된다.
144
+
117
145
  ## 정직한 한계
118
146
 
119
147
  - 사후 대조가 아닌 **구현 전 게이트**다 — "도중 탈선"은 못 잡는다(설계상 후속 C 영역).
120
148
  - 판정은 LLM이라 100% 아니다. **사람이 변이 전 케이스를 리뷰/편집하는 pause**가 진짜 가치다.
121
- - MVP scope = **B-커널**(CC-native hook + defer-registry + /gate). standalone TUI·추출 모드·계측 대시보드는 후속.
149
+ - MVP scope = **B-커널**(CC-native hook + defer-registry + /gate). standalone TUI·추출 모드는 후속 A(public). 계측은 B-모드 관측 프록시(M1~M3)까지 구현됨(위 [계측](#계측-m1m3)).
122
150
  - **검증 상태**: 게이트 판정 품질은 **양 트랜스포트 모두 회귀 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`로 재현).
123
- - **fail-open**: 판정 호출이 실패하면(키 오류·네트워크 등) 게이트는 조용히 통과시킨다(개발 차단 방지). 게이트가 무력화돼도 경고가 약하다는 트레이드오프 후속 관찰 항목.
151
+ - **fail-open**: 판정 호출이 실패하면(키 오류·네트워크 등) 게이트는 안전하게 통과시킨다(개발 차단 방지). fail-open 통과는 작업단위 캐시에서 제외되고(다음 편집 재판정), `systemMessage` 경고 + `.gbc/failopen.log` 계측으로 드러난다(조용한 무력화 방지).
124
152
 
125
153
  ## 라이선스
126
154
 
package/dist/cli.js CHANGED
@@ -9,7 +9,8 @@ import { loadPlanSpec, computeSpecHash, addSpecCase, readSpecCases, clearSpec }
9
9
  import { loadState, resetGate } from "./state.js";
10
10
  import { addDefer, loadDefers, resolveDefer } from "./defer.js";
11
11
  import { selectedTransport } from "./judge.js";
12
- import { buildPreCommand, upgradeKeylessHooks } from "./install.js";
12
+ import { buildPreCommand, normalizeHooks } from "./install.js";
13
+ import { logEvent, parseEvents, computeMetrics } from "./metrics.js";
13
14
  const CLI_PATH = fileURLToPath(import.meta.url);
14
15
  const PKG_ROOT = join(dirname(CLI_PATH), ".."); // dist/cli.js → 패키지 루트
15
16
  /** ~/.gbc/api-key 존재 여부 — 있으면 hook에 키 주입(빠른 haiku 경로). */
@@ -19,6 +20,26 @@ function hasApiKey() {
19
20
  function stopCommand() {
20
21
  return `node "${CLI_PATH}" hook stop`;
21
22
  }
23
+ function nowIso() {
24
+ try {
25
+ return new Date().toISOString();
26
+ }
27
+ catch {
28
+ return "";
29
+ }
30
+ }
31
+ /**
32
+ * 현재 작업단위 명세 해시 (CLI 이벤트의 specHash 상관 키).
33
+ * 빈 spec은 ""(센티넬) — M1 churn 교차세션 합산 방지(computeMetrics가 제외).
34
+ */
35
+ function curHash(cwd) {
36
+ const text = loadPlanSpec(cwd).text;
37
+ return text.trim() === "" ? "" : computeSpecHash(text);
38
+ }
39
+ /** CLI 변이 이벤트를 events.jsonl에 기록(메트릭 상관용). specHash는 변이 전 값을 넘긴다. */
40
+ function logCli(cwd, kind, specHash) {
41
+ logEvent(cwd, { at: nowIso(), session: "", specHash, kind });
42
+ }
22
43
  function nowStamp() {
23
44
  try {
24
45
  return new Date().toISOString().replace(/[:.]/g, "-");
@@ -42,8 +63,10 @@ function cmdInit(args) {
42
63
  1) ${settingsPath} 에 PreToolUse(Edit|Write) + Stop hook 추가 (머지·멱등)
43
64
  - 기존 settings.json 있으면 백업: settings.json.bak-<시각>
44
65
  2) ${join(skillDestDir, "SKILL.md")} 에 /gate 스킬 설치
45
- 3) hook 명령: ${buildPreCommand(CLI_PATH, hasApiKey())}
46
-
66
+ 3) hook 명령: ${buildPreCommand(CLI_PATH)}
67
+ ${hasApiKey()
68
+ ? " (~/.gbc/api-key 감지됨 → 빠른 haiku API 경로로 동작)"
69
+ : " (~/.gbc/api-key 없음 → claude -p 폴백. 빠른 경로 원하면 키 파일 생성)"}
47
70
  실행하려면: gbc init --yes
48
71
  `);
49
72
  return;
@@ -65,21 +88,20 @@ function cmdInit(args) {
65
88
  }
66
89
  const hooks = (settings.hooks ??= {});
67
90
  const serialized = JSON.stringify(settings);
68
- const useKey = hasApiKey();
69
- // PreToolUse (멱등). 신규면 추가, 이미 있으면 keyless→키주입 업그레이드(skip만 하지 않음).
91
+ // PreToolUse (멱등). 신규면 추가, 이미 있으면 옛 명령(keyless·bash 키주입)을 pure로 정규화.
70
92
  if (!serialized.includes("hook pre-tool-use")) {
71
93
  (hooks.PreToolUse ??= []).push({
72
94
  matcher: "Edit|Write|MultiEdit",
73
- hooks: [{ type: "command", command: buildPreCommand(CLI_PATH, useKey) }],
95
+ hooks: [{ type: "command", command: buildPreCommand(CLI_PATH) }],
74
96
  });
75
- console.log(` + PreToolUse hook 추가${useKey ? " (API 키 주입)" : ""}`);
97
+ console.log(` + PreToolUse hook 추가`);
76
98
  }
77
99
  else {
78
- const n = useKey ? upgradeKeylessHooks(settings, CLI_PATH, true) : 0;
100
+ const n = normalizeHooks(settings, CLI_PATH);
79
101
  if (n > 0)
80
- console.log(` ↑ PreToolUse hook 키주입 업그레이드 (${n})`);
102
+ console.log(` ↑ PreToolUse hook 정규화 (${n}건, 셸 무관 명령으로)`);
81
103
  else
82
- console.log(` = PreToolUse hook 이미 존재 (skip)`);
104
+ console.log(` = PreToolUse hook 이미 표준 (skip)`);
83
105
  }
84
106
  // Stop (멱등)
85
107
  if (!serialized.includes("hook stop")) {
@@ -133,6 +155,7 @@ function cmdDefer(args) {
133
155
  process.exit(1);
134
156
  }
135
157
  addDefer(cwd, item);
158
+ logCli(cwd, "defer-add", curHash(cwd));
136
159
  console.log(`🐢 미룸 등록: ${item}`);
137
160
  }
138
161
  else if (sub === "list") {
@@ -146,6 +169,8 @@ function cmdDefer(args) {
146
169
  else if (sub === "resolve") {
147
170
  const ref = args.slice(1).join(" ").trim();
148
171
  const r = resolveDefer(cwd, ref);
172
+ if (r)
173
+ logCli(cwd, "defer-resolve", curHash(cwd));
149
174
  console.log(r ? `🐢 해결 표시: ${r.item}` : `매칭되는 미룬 항목 없음: ${ref}`);
150
175
  }
151
176
  else {
@@ -163,7 +188,9 @@ function cmdSpec(args) {
163
188
  console.error('사용: gbc spec add "<케이스/시나리오>"');
164
189
  process.exit(1);
165
190
  }
191
+ const beforeHash = curHash(cwd); // 변이 전 해시 = 수정 대상 작업단위와 상관
166
192
  addSpecCase(cwd, item);
193
+ logCli(cwd, "spec-add", beforeHash);
167
194
  console.log(`🐢 명세 등록: ${item}`);
168
195
  }
169
196
  else if (sub === "show") {
@@ -175,7 +202,9 @@ function cmdSpec(args) {
175
202
  cases.forEach((c, i) => console.log(`${i + 1}. ${c}`));
176
203
  }
177
204
  else if (sub === "clear") {
205
+ const beforeHash = curHash(cwd);
178
206
  clearSpec(cwd);
207
+ logCli(cwd, "spec-clear", beforeHash);
179
208
  console.log("🐢 명세 비움 — 다음 작업단위로 깨끗이 넘어갑니다.");
180
209
  }
181
210
  else {
@@ -186,7 +215,9 @@ function cmdSpec(args) {
186
215
  // ---------- gbc gate ----------
187
216
  function cmdGate(args) {
188
217
  if (args[0] === "reset") {
189
- resetGate(process.cwd());
218
+ const cwd = process.cwd();
219
+ logCli(cwd, "gate-reset", curHash(cwd));
220
+ resetGate(cwd);
190
221
  console.log("🐢 작업단위 게이트 리셋 — 다음 편집에서 다시 발동합니다.");
191
222
  }
192
223
  else {
@@ -194,6 +225,30 @@ function cmdGate(args) {
194
225
  process.exit(1);
195
226
  }
196
227
  }
228
+ // ---------- gbc metrics ----------
229
+ function cmdMetrics(args) {
230
+ const cwd = process.cwd();
231
+ const eventsPath = join(cwd, ".gbc", "events.jsonl");
232
+ const raw = existsSync(eventsPath) ? readFileSync(eventsPath, "utf8") : "";
233
+ const m = computeMetrics(parseEvents(raw));
234
+ if (args.includes("--json")) {
235
+ console.log(JSON.stringify(m, null, 2));
236
+ return;
237
+ }
238
+ console.log(`🐢 거북이 게이트 계측 — ${cwd}
239
+ 이벤트 총 ${m.totalEvents}건 (.gbc/events.jsonl)
240
+
241
+ [M3] 재호출/iteration — 작업단위당 edit 반복
242
+ 작업단위 ${m.m3.workUnits} · 총 edit ${m.m3.totalEdits} · 평균 ${m.m3.avgEditsPerUnit}/단위 · 최대 ${m.m3.maxEditsPerUnit} · 반복(>1)단위 ${m.m3.multiEditUnits}
243
+
244
+ [M2] 게이트 적중 vs 도중발견
245
+ 게이트 적중(차단 누락케이스) ${m.m2.gateCaught} · 차단 ${m.m2.blocks}회
246
+ 도중발견(defer 등록) ${m.m2.deferred} · 도중발견 비율 ${(m.m2.midDiscoveryRatio * 100).toFixed(1)}%
247
+
248
+ [M1] post-gate 재작업
249
+ 게이트 리셋 ${m.m1.resets} · 통과후 churn ${m.m1.churnAfterPass}
250
+ ⚠️ ${m.m1.note}`);
251
+ }
197
252
  function usage() {
198
253
  console.log(`🐢 gbc — 거북이코드 구현-전 게이트
199
254
 
@@ -207,6 +262,7 @@ function usage() {
207
262
  gbc spec show 등록된 케이스 목록
208
263
  gbc spec clear 명세 비우기(작업단위 종료)
209
264
  gbc gate reset 작업단위 게이트 리셋
265
+ gbc metrics [--json] 계측 리포트(M1~M3, B-모드 관측 프록시)
210
266
  gbc hook pre-tool-use (내부) PreToolUse hook
211
267
  gbc hook stop (내부) Stop hook
212
268
  `);
@@ -232,6 +288,8 @@ async function main() {
232
288
  return cmdSpec(rest);
233
289
  case "gate":
234
290
  return cmdGate(rest);
291
+ case "metrics":
292
+ return cmdMetrics(rest);
235
293
  case undefined:
236
294
  case "help":
237
295
  case "--help":
package/dist/hook.js CHANGED
@@ -8,6 +8,7 @@ import { activeDeferItems, unresolvedDefers, loadDefers } from "./defer.js";
8
8
  import { appendFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { gbcDir } from "./store.js";
11
+ import { logEvent } from "./metrics.js";
11
12
  /**
12
13
  * 차단 사유 메시지를 빌드한다. 두 차단 종류를 다르게 안내한다:
13
14
  * - specEmpty=true (시나리오 미지정): 에이전트가 요청에서 시나리오를 도출 → 사용자 검증 →
@@ -48,6 +49,14 @@ function readStdin() {
48
49
  function emit(obj) {
49
50
  process.stdout.write(JSON.stringify(obj));
50
51
  }
52
+ function nowIso() {
53
+ try {
54
+ return new Date().toISOString();
55
+ }
56
+ catch {
57
+ return "";
58
+ }
59
+ }
51
60
  function logBypass(cwd, toolName) {
52
61
  try {
53
62
  appendFileSync(join(gbcDir(cwd), "bypass.log"), `${new Date().toISOString()} ${toolName}\n`);
@@ -78,19 +87,34 @@ export async function runPreToolUse() {
78
87
  }
79
88
  const toolName = input.tool_name ?? "";
80
89
  const cwd = input.cwd || process.cwd();
90
+ const session = input.session_id ?? "";
81
91
  // 코드 변경 도구가 아니면 즉시 통과
82
92
  if (!isGatedTool(toolName))
83
93
  process.exit(0);
84
94
  // 명시적 우회 (계측됨)
85
95
  if (process.env.GBC_NO_GATE === "1") {
86
96
  logBypass(cwd, toolName);
97
+ logEvent(cwd, { at: nowIso(), session, specHash: "", kind: "bypass", tool: toolName });
87
98
  process.exit(0);
88
99
  }
89
100
  const { text: specText, source } = loadPlanSpec(cwd);
90
101
  const specHash = computeSpecHash(specText);
102
+ // 계측용 해시: 빈 spec은 ""(센티넬)로 기록 → M1 churn 교차세션 합산 방지.
103
+ // (게이트 캐시용 specHash는 그대로 — markGated/isGated 동작 불변)
104
+ const logHash = specText.trim() === "" ? "" : specHash;
91
105
  // 작업단위 1회: 이미 게이트 통과한 단위면 즉시 통과 (judge 미호출, 핫패스)
92
- if (isGated(cwd, specHash))
106
+ // 계측: cached-skip도 기록해야 M3(작업단위당 edit 반복)이 진짜 횟수를 잡는다.
107
+ if (isGated(cwd, specHash)) {
108
+ logEvent(cwd, {
109
+ at: nowIso(),
110
+ session,
111
+ specHash: logHash,
112
+ kind: "gate",
113
+ tool: toolName,
114
+ decision: "cached",
115
+ });
93
116
  process.exit(0);
117
+ }
94
118
  // judge는 여기서만 동적 import (SDK lazy)
95
119
  const { judge } = await import("./judge.js");
96
120
  const editText = normalizeEdit(toolName, input.tool_input ?? {});
@@ -99,11 +123,28 @@ export async function runPreToolUse() {
99
123
  if (verdict.verdict === "pass") {
100
124
  if (shouldCacheVerdict(verdict)) {
101
125
  markGated(cwd, specHash, verdict.reason);
126
+ logEvent(cwd, {
127
+ at: nowIso(),
128
+ session,
129
+ specHash: logHash,
130
+ kind: "gate",
131
+ tool: toolName,
132
+ decision: "pass",
133
+ deferCount: defers.length,
134
+ });
102
135
  process.exit(0); // 정상 통과 (자동승인 X — 무출력)
103
136
  }
104
137
  // fail-open: 판정 실패로 안전 통과. 캐시하지 않아(작업단위 무력화 방지) 다음 편집에서 재판정.
105
138
  // 계측 + systemMessage로 사용자에게 "게이트가 검사 못 했음"을 알린다(조용한 무력화 방지).
106
139
  logFailOpen(cwd, toolName, verdict.reason);
140
+ logEvent(cwd, {
141
+ at: nowIso(),
142
+ session,
143
+ specHash: logHash,
144
+ kind: "gate",
145
+ tool: toolName,
146
+ decision: "failopen",
147
+ });
107
148
  emit({
108
149
  systemMessage: `🐢 거북이 게이트 — 판정 실패로 안전 통과(fail-open). 이 편집은 게이트 검사를 받지 못했습니다: ${verdict.reason}`,
109
150
  hookSpecificOutput: {
@@ -117,6 +158,16 @@ export async function runPreToolUse() {
117
158
  // block: 사람 pause (ask 기본) — 사유가 사용자에게 표시됨
118
159
  // 시나리오 미지정(명세 빈약)과 침묵 누락을 다르게 안내한다.
119
160
  const reason = buildBlockReason(verdict, specText.trim() === "", source);
161
+ logEvent(cwd, {
162
+ at: nowIso(),
163
+ session,
164
+ specHash: logHash,
165
+ kind: "gate",
166
+ tool: toolName,
167
+ decision: "block",
168
+ missing: verdict.missing,
169
+ deferCount: defers.length,
170
+ });
120
171
  const mode = process.env.GBC_BLOCK_MODE === "deny" ? "deny" : "ask";
121
172
  emit({
122
173
  hookSpecificOutput: {
package/dist/install.js CHANGED
@@ -1,39 +1,33 @@
1
1
  // gbc init 설치 로직 (순수함수 — cli.ts main() 부작용 없이 단위테스트 가능).
2
- // 키 주입은 확장($HOME)으로 머신 독립적이게 한다(하드코딩 홈경로 금지).
2
+ // 키 주입은 셸이 아니라 gbc 코드(judge.ts resolveApiKey) 처리한다 hook 명령은
3
+ // 셸 무관 순수 형태라 native Windows(cmd.exe)/bash/zsh/Mac에서 동일하게 동작한다.
3
4
  /**
4
- * PreToolUse hook 명령 생성.
5
- * useKey=true면 ANTHROPIC_API_KEY를 $HOME/.gbc/api-key에서 읽어 주입(빠른 haiku 경로).
6
- * 경로는 셸이 확장하는 $HOME을 머신 독립적이게 한다.
5
+ * PreToolUse hook 명령 생성 — 셸 무관 순수 명령.
6
+ * `node "<cliPath>" hook pre-tool-use` 형태만 생성한다. 키 주입( prefix)·셸 확장 없음.
7
+ * - cliPath는 큰따옴표로만 감싼다(공백 포함 경로 안전). 큰따옴표는 cmd.exe·POSIX sh 공통.
8
+ * - 백슬래시를 이스케이프하지 않는다: Windows 경로(C:\...)의 구분자이며, settings.json에
9
+ * 기록될 때 cli.ts의 JSON.stringify가 `\`→`\\` 처리를 담당한다(여기서 또 하면 이중).
10
+ * - cliPath는 import.meta.url 기반 설치 경로(사용자 입력 아님)라 셸 인젝션 위험이 실질적으로
11
+ * 없어 별도 메타문자 이스케이프를 두지 않는다(이전 shDquote 방어 제거 — 보안 재검토 반영).
7
12
  */
8
- /**
9
- * 더블쿼트 컨텍스트용 이스케이프. 셸이 확장하거나 따옴표를 벗어나지 못하도록
10
- * `"` 백틱 `$` `\` 를 백슬래시 처리한다(settings.json 명령 인젝션 방지).
11
- */
12
- function shDquote(s) {
13
- return s.replace(/(["`$\\])/g, "\\$1");
14
- }
15
- export function buildPreCommand(cliPath, useKey) {
16
- const base = `node "${shDquote(cliPath)}" hook pre-tool-use`;
17
- // $HOME·$(...)는 의도된 셸 확장이므로 이스케이프하지 않는다.
18
- return useKey ? `ANTHROPIC_API_KEY="$(cat "$HOME/.gbc/api-key")" ${base}` : base;
13
+ export function buildPreCommand(cliPath) {
14
+ return `node "${cliPath}" hook pre-tool-use`;
19
15
  }
20
16
  /**
21
- * 이미 설치된 keyless PreToolUse hook command를 주입 버전으로 업그레이드.
22
- * settings를 제자리 수정하고, 업그레이드한 건수를 반환한다(멱등: 이미 키주입된 건너뜀).
17
+ * 기존 PreToolUse hook 명령을 현재 표준(셸 무관 pure 명령)으로 정규화한다.
18
+ * keyless 명령·옛 bash 키주입 prefix 명령을 모두 pure로 교체 → "모든 OS 동일 명령" 목표 달성.
19
+ * settings를 제자리 수정하고 변경 건수를 반환한다(멱등: 이미 표준이면 0건).
23
20
  */
24
- export function upgradeKeylessHooks(settings, cliPath, useKey) {
25
- if (!useKey)
26
- return 0;
27
- const target = buildPreCommand(cliPath, true);
28
- let upgraded = 0;
21
+ export function normalizeHooks(settings, cliPath) {
22
+ const target = buildPreCommand(cliPath);
23
+ let changed = 0;
29
24
  for (const entry of settings.hooks?.PreToolUse ?? []) {
30
25
  for (const h of entry.hooks ?? []) {
31
- // pre-tool-use hook인데 아직 주입이 없으면(keyless) 교체
32
- if (h.command.includes("hook pre-tool-use") && !h.command.includes("ANTHROPIC_API_KEY")) {
26
+ if (h.command.includes("hook pre-tool-use") && h.command !== target) {
33
27
  h.command = target;
34
- upgraded++;
28
+ changed++;
35
29
  }
36
30
  }
37
31
  }
38
- return upgraded;
32
+ return changed;
39
33
  }
package/dist/judge.js CHANGED
@@ -1,7 +1,31 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
3
6
  const execFileAsync = promisify(execFile);
4
7
  const MODEL = process.env.GBC_MODEL ?? "claude-haiku-4-5";
8
+ /**
9
+ * API 키 해석 (크로스플랫폼, 셸 무관).
10
+ * 1) ANTHROPIC_API_KEY 환경변수 우선, 2) 없으면 ~/.gbc/api-key 파일.
11
+ * STUB
12
+ */
13
+ export function resolveApiKey(opts = {}) {
14
+ const env = opts.env ?? process.env;
15
+ const fromEnv = env.ANTHROPIC_API_KEY;
16
+ if (fromEnv && fromEnv.trim())
17
+ return fromEnv;
18
+ const home = opts.homeDir ?? homedir();
19
+ const read = opts.readFile ?? ((p) => readFileSync(p, "utf8"));
20
+ try {
21
+ // bash `$(cat)`는 trailing newline을 벗기지만 readFileSync는 안 벗긴다 → 명시 trim 필수.
22
+ const key = read(join(home, ".gbc", "api-key")).trim();
23
+ return key || null;
24
+ }
25
+ catch {
26
+ return null; // 파일 부재/읽기 실패 → 키 없음(claude -p 폴백)
27
+ }
28
+ }
5
29
  /**
6
30
  * 최소 게이트 시스템 프롬프트.
7
31
  * 의미론(핵심): 한 편집이 모든 케이스를 *완전 구현*할 필요는 없다.
@@ -53,15 +77,16 @@ function parseVerdict(raw) {
53
77
  reason: typeof j.reason === "string" ? j.reason : "",
54
78
  };
55
79
  }
56
- /** 트랜스포트 선택 결과 (디버그/리포트용) */
80
+ /** 트랜스포트 선택 결과 (디버그/리포트용). env 또는 키파일에 키가 있으면 api. */
57
81
  export function selectedTransport() {
58
- return process.env.ANTHROPIC_API_KEY ? "api" : "cli";
82
+ return resolveApiKey() ? "api" : "cli";
59
83
  }
60
84
  /** 직접 Anthropic API (haiku). SDK는 여기서만 lazy import → hook 핫패스 보호. */
61
85
  async function judgeViaApi(system, user) {
62
86
  const mod = await import("@anthropic-ai/sdk");
63
87
  const Anthropic = mod.default;
64
- const client = new Anthropic(); // ANTHROPIC_API_KEY 환경변수 사용
88
+ // 키를 코드에서 해석(env 또는 ~/.gbc/api-key) 명시 전달 셸 주입 불필요(크로스플랫폼).
89
+ const client = new Anthropic({ apiKey: resolveApiKey() ?? undefined });
65
90
  const resp = await client.messages.create({
66
91
  model: MODEL,
67
92
  max_tokens: 1024,
@@ -0,0 +1,120 @@
1
+ // 거북이코드 계측 레이어 (M1~M3) — B-모드 hook 관측 프록시.
2
+ // 1차 자산 = 원시 events.jsonl(append-only). 메트릭은 그 위의 thin 집계.
3
+ // ⚠️ 진짜 M1(post-gate 시나리오위반율)은 A-mode 사후대조 필요 — B-모드는 churn 약신호만.
4
+ import { appendFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { gbcDir } from "./store.js";
7
+ /** 한 줄 이벤트의 최대 바이트 — O_APPEND atomic 보장(미만 길이) */
8
+ const MAX_LINE = 4096;
9
+ /** missing[] 캡 (항목 수 / 항목당 길이) */
10
+ const MAX_MISSING_ITEMS = 20;
11
+ const MAX_MISSING_LEN = 200;
12
+ /** missing[]을 항목 수/길이로 캡 */
13
+ function capMissing(missing) {
14
+ return missing
15
+ .slice(0, MAX_MISSING_ITEMS)
16
+ .map((m) => (m.length > MAX_MISSING_LEN ? m.slice(0, MAX_MISSING_LEN) : m));
17
+ }
18
+ /**
19
+ * 이벤트를 한 줄 JSON으로 직렬화. missing[]을 캡하고, 그래도 MAX_LINE을 넘으면
20
+ * missing을 요약 토큰으로 대체해 라인 길이를 보장한다(O_APPEND atomic).
21
+ */
22
+ export function serializeEvent(e) {
23
+ const out = { ...e };
24
+ if (out.missing)
25
+ out.missing = capMissing(out.missing);
26
+ let line = JSON.stringify(out);
27
+ if (line.length >= MAX_LINE && out.missing) {
28
+ out.missing = [`${e.missing?.length ?? 0} items (truncated)`];
29
+ line = JSON.stringify(out);
30
+ }
31
+ // 극단적 경우(다른 필드가 비대)에도 캡 — 한 줄 보장
32
+ if (line.length >= MAX_LINE)
33
+ line = line.slice(0, MAX_LINE - 1);
34
+ return line;
35
+ }
36
+ /** jsonl 원시 텍스트를 이벤트 배열로 파싱 (빈 줄·깨진 줄 skip) */
37
+ export function parseEvents(raw) {
38
+ const out = [];
39
+ for (const line of raw.split("\n")) {
40
+ const t = line.trim();
41
+ if (!t)
42
+ continue;
43
+ try {
44
+ const obj = JSON.parse(t);
45
+ if (obj && typeof obj === "object" && typeof obj.kind === "string") {
46
+ out.push(obj);
47
+ }
48
+ }
49
+ catch {
50
+ /* 깨진 줄 skip */
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ const M1_NOTE = "B-모드 약신호(churn proxy) — 진짜 M1(post-gate 시나리오 위반율)은 A-mode 사후대조 필요. " +
56
+ "spec.md 비었을 때(specHash='')는 작업단위 식별 불가라 churn 집계에서 제외(교차세션 합산 방지).";
57
+ /** 그룹핑 키: session 우선, 없으면 specHash(CLI 이벤트 상관) */
58
+ function groupKey(e) {
59
+ return e.session || e.specHash;
60
+ }
61
+ function round3(n) {
62
+ return Math.round(n * 1000) / 1000;
63
+ }
64
+ /** 이벤트 배열 → M1/M2/M3 집계. 순수함수(파일 I/O 없음). */
65
+ export function computeMetrics(events) {
66
+ const gate = events.filter((e) => e.kind === "gate");
67
+ // M3 — 작업단위(session||specHash)별 gate 이벤트 수 = edit 반복 proxy
68
+ const perUnit = new Map();
69
+ for (const e of gate)
70
+ perUnit.set(groupKey(e), (perUnit.get(groupKey(e)) ?? 0) + 1);
71
+ const counts = [...perUnit.values()];
72
+ const workUnits = counts.length;
73
+ const totalEdits = gate.length;
74
+ const maxEditsPerUnit = counts.length ? Math.max(...counts) : 0;
75
+ const multiEditUnits = counts.filter((c) => c > 1).length;
76
+ const avgEditsPerUnit = workUnits ? round3(totalEdits / workUnits) : 0;
77
+ // M2 — 게이트적중(Σ block.missing) vs 도중발견(defer-add)
78
+ const blockEvents = gate.filter((e) => e.decision === "block");
79
+ const gateCaught = blockEvents.reduce((s, e) => s + (e.missing?.length ?? 0), 0);
80
+ const deferred = events.filter((e) => e.kind === "defer-add").length;
81
+ const denom = gateCaught + deferred;
82
+ const midDiscoveryRatio = denom ? round3(deferred / denom) : 0;
83
+ // M1 — specHash별 first pass 이후의 churn(spec-add/clear/gate-reset/defer-add).
84
+ // ⚠️ 빈 specHash("")는 spec.md 없는 작업단위라 식별 불가 → 교차세션 합산을 막기 위해 제외.
85
+ const firstPassAt = new Map();
86
+ for (const e of gate) {
87
+ if (e.decision !== "pass" || !e.specHash)
88
+ continue;
89
+ const cur = firstPassAt.get(e.specHash);
90
+ if (cur === undefined || e.at < cur)
91
+ firstPassAt.set(e.specHash, e.at);
92
+ }
93
+ const CHURN_KINDS = ["spec-add", "spec-clear", "gate-reset", "defer-add"];
94
+ let churnAfterPass = 0;
95
+ for (const e of events) {
96
+ if (!CHURN_KINDS.includes(e.kind) || !e.specHash)
97
+ continue;
98
+ const passAt = firstPassAt.get(e.specHash);
99
+ if (passAt !== undefined && e.at > passAt)
100
+ churnAfterPass++;
101
+ }
102
+ const resets = events.filter((e) => e.kind === "gate-reset").length;
103
+ return {
104
+ totalEvents: events.length,
105
+ m3: { workUnits, totalEdits, avgEditsPerUnit, maxEditsPerUnit, multiEditUnits },
106
+ m2: { gateCaught, blocks: blockEvents.length, deferred, midDiscoveryRatio },
107
+ m1: { resets, churnAfterPass, note: M1_NOTE },
108
+ };
109
+ }
110
+ /** events.jsonl에 이벤트 1줄 append. 실패는 무시(계측이 개발 흐름을 막지 않음). */
111
+ export function logEvent(cwd, event) {
112
+ if (process.env.GBC_NO_METRICS === "1")
113
+ return;
114
+ try {
115
+ appendFileSync(join(gbcDir(cwd), "events.jsonl"), serializeEvent(event) + "\n");
116
+ }
117
+ catch {
118
+ /* 계측 실패는 무시 */
119
+ }
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geobuke-code",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "거북이코드 — 구현 직전 강제 게이트. Claude Code PreToolUse hook으로 코드 변경 전 계획 케이스 누락·시나리오 미지정을 차단한다.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,8 @@
18
18
  "test": "node --test 'test/**/*.test.mjs'",
19
19
  "eval": "node dist/eval/regression.js",
20
20
  "prepare": "tsc",
21
- "prepublishOnly": "npm run build && npm test"
21
+ "prepublishOnly": "npm run build && npm test",
22
+ "release": "sh scripts/publish.sh"
22
23
  },
23
24
  "keywords": [
24
25
  "claude-code",