geobuke-code 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cubha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # 거북이코드 (geobuke-code)
2
+
3
+ > **구현 직전 강제 게이트.** Claude Code의 PreToolUse hook으로, 코드를 쓰기 *전에* 계획 케이스의 침묵 누락과 시나리오 미지정을 차단한다.
4
+
5
+ `gbc`는 기존 코딩 에이전트(Claude Code) 위에 얹는 **얇은 게이트**다. 모델 계층을 소유하지 않는다 — 판단용 작은 호출(haiku)만 직접 하고, 코드 생성은 그대로 Claude Code가 한다.
6
+
7
+ ## 무엇을 푸는가
8
+
9
+ 구현 전에 강제되지 않는 두 가지가 반복 통증을 만든다:
10
+
11
+ 1. **선행 케이스를 "추후작업"으로 미루다 누락** → 설계 공백 → 큰 결함
12
+ 2. **시나리오 미지정으로 임의 구현** → 의도와 다른 동작
13
+
14
+ 게이트는 코드 변경(Edit/Write/MultiEdit) 직전에 끼어들어:
15
+
16
+ - 계획 명세에 있는 케이스가 **침묵 누락**(언급도 등록도 없이 빠짐)되면 차단
17
+ - 의도·동작 **시나리오가 미지정**인 채 구현되면 차단
18
+ - **미루기는 명시 등록(`gbc defer add`)만 허용** — 침묵 누락 차단의 forcing function
19
+
20
+ 게이트는 *완전 구현*을 요구하지 않는다. 케이스가 다뤄지기 시작했거나 명시 defer되면 통과한다.
21
+
22
+ ## 설치
23
+
24
+ > ⚠️ **현재 npm 미발행** — 아래 **로컬 개발 설치**가 유일한 경로다. (`npm install -g geobuke-code` 공개배포는 후속 A(public) 단계.)
25
+
26
+ ```bash
27
+ # 1) 클론 + 빌드 (dist/ 생성)
28
+ git clone https://github.com/cubha/geobuke-code.git
29
+ cd geobuke-code
30
+ npm install && npm run build
31
+
32
+ # 2) gbc 명령 연결 — 택1
33
+ npm link # 권한 OK면 전역 gbc 생성
34
+ # ↑ EACCES(전역 node_modules가 root 소유)면 sudo 없이 PATH의 ~/.local/bin에 wrapper:
35
+ printf '#!/bin/sh\nexec node "%s/dist/cli.js" "$@"\n' "$PWD" > ~/.local/bin/gbc && chmod +x ~/.local/bin/gbc
36
+
37
+ # 3) 대상 프로젝트에 게이트 설치
38
+ cd <your-project>
39
+ gbc init # .claude/settings.json에 hook + /gate skill 머지 (동의·백업)
40
+ ```
41
+
42
+ `gbc init`은 **프로젝트 로컬 `.claude/settings.json`만** 머지한다(append·멱등·백업). 전역 `~/.claude`는 건드리지 않는다.
43
+
44
+ 소스를 수정하면 `npm run build`만 다시 하면 된다 — wrapper/link는 같은 `dist/cli.js`를 가리키므로 재연결 불필요.
45
+
46
+ ## 빠른 게이트 활성화 (API 키 — 선택)
47
+
48
+ `gbc init`은 키 주입 **없는** hook을 설치한다 → 기본은 `claude -p` 폴백(~13–20s). **haiku 직접 API(~1–3s)**를 쓰려면 키를 **hook 명령에만** 주입한다.
49
+
50
+ > ⚠️ **`export ANTHROPIC_API_KEY=…` 전역 설정 금지.** Claude Code 본체가 그 키로 **과금 전환**된다(구독 대신 키 과금). 게이트 hook 서브프로세스에만 주입해야 안전하다.
51
+
52
+ ```bash
53
+ # 1) 키를 파일에 저장 (권한 600)
54
+ mkdir -p ~/.gbc && printf '%s' 'sk-ant-...' > ~/.gbc/api-key && chmod 600 ~/.gbc/api-key
55
+ ```
56
+
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"
62
+ ```
63
+
64
+ `gbc status`엔 `트랜스포트: cli`로 보일 수 있다(status 명령 자체엔 키가 없어서). 무관하다 — 실제 hook 발동 시엔 위 주입으로 `api(haiku)` 경로로 동작한다.
65
+
66
+ ## 동작 원리
67
+
68
+ ```
69
+ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이스확정】 → 구현(Claude Code) → 검증
70
+ ```
71
+
72
+ 게이트는 계획 명세를 다음 우선순위로 읽는다(durable 소스):
73
+ `$GBC_SPEC_FILE` > `.gbc/spec.md` > `scratch.md`
74
+
75
+ 코드 변경 직전 PreToolUse hook이 명세 ↔ 변경 ↔ 미룬 항목을 대조해 통과/차단을 판정한다.
76
+
77
+ ### 시나리오 도출 루프 (수기 입력 불필요)
78
+
79
+ 명세가 비어 **시나리오 미지정**으로 차단되면, 사용자가 파일을 직접 쓰지 않는다. 차단 메시지가 코딩 에이전트에게 다음을 지시한다:
80
+
81
+ ```
82
+ 요청에서 시나리오 도출 → 사용자에게 제시·검증 → gbc spec add로 등록 → 재시도
83
+ ```
84
+
85
+ - **도출**은 코딩 에이전트 본체(Opus, 대화 맥락 보유)가, **게이트 판정**은 haiku가 한다 — 두 작업/두 모델 분리. gbc는 모델 계층을 소유하지 않는다(판단용 작은 호출만).
86
+ - **사용자 검증은 양보 불가**다 — 같은 에이전트가 도출+구현까지 자동으로 하면 자기 시나리오만 통과시키는 고무도장이 된다. 승인 없는 자동 등록을 금지한다.
87
+
88
+ ## 지연(latency)과 트랜스포트
89
+
90
+ 판정은 작은 LLM 호출이다. 두 트랜스포트:
91
+
92
+ | 조건 | 트랜스포트 | 지연 |
93
+ |---|---|---|
94
+ | `ANTHROPIC_API_KEY` 설정됨 | Anthropic API 직접 (haiku, 최소 시스템프롬프트) | ~1–3s (목표) |
95
+ | 미설정 | `claude -p` 폴백 (CC 인증 재사용, 무설정) | ~13–20s |
96
+
97
+ **작업단위 1회**: 게이트는 작업단위(계획 명세 해시)당 한 번만 발동한다. 명세가 바뀌거나 명세 밖 파일을 편집할 때만 재발동 → 매 편집 지연을 피한다.
98
+
99
+ > 빠른 게이트를 원하면 `ANTHROPIC_API_KEY`를 설정하라(설정법·과금 주의: 위 [「빠른 게이트 활성화」](#빠른-게이트-활성화-api-키--선택)). 없으면 `claude -p` 폴백으로 무설정 동작하되 작업단위당 한 번 느리다.
100
+
101
+ ## 명령
102
+
103
+ | 명령 | 설명 |
104
+ |---|---|
105
+ | `gbc init` | hook + /gate skill 설치 |
106
+ | `gbc status` | 게이트 상태 + 로드된 명세 확인 |
107
+ | `gbc defer add "<케이스>"` | 케이스를 명시적으로 미루기 |
108
+ | `gbc defer list` | 미룬 항목 목록 |
109
+ | `gbc defer resolve <번호\|텍스트>` | 미룬 항목 해결 |
110
+ | `gbc spec add "<케이스>"` | 승인된 시나리오를 `.gbc/spec.md`에 등록 |
111
+ | `gbc spec show` | 등록된 케이스 목록 |
112
+ | `gbc spec clear` | 명세 비우기(작업단위 종료) |
113
+ | `gbc gate reset` | 작업단위 게이트 리셋 |
114
+
115
+ 우회: `GBC_NO_GATE=1` (계측됨 — 우회 자체가 게이트 가치 측정 데이터).
116
+
117
+ ## 정직한 한계
118
+
119
+ - 사후 대조가 아닌 **구현 전 게이트**다 — "도중 탈선"은 못 잡는다(설계상 후속 C 영역).
120
+ - 판정은 LLM이라 100% 아니다. **사람이 변이 전 케이스를 리뷰/편집하는 pause**가 진짜 가치다.
121
+ - MVP scope = **B-커널**(CC-native hook + defer-registry + /gate). standalone TUI·추출 모드·계측 대시보드는 후속.
122
+ - **검증 상태**: 게이트 판정 품질은 **양 트랜스포트 모두 회귀 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**: 판정 호출이 실패하면(키 오류·네트워크 등) 게이트는 조용히 통과시킨다(개발 차단 방지). 게이트가 무력화돼도 경고가 약하다는 트레이드오프 — 후속 관찰 항목.
124
+
125
+ ## 라이선스
126
+
127
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ // gbc — 거북이코드 CLI. zero-dep 인자 파싱(핫패스 보호).
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, } from "node:fs";
7
+ import { runPreToolUse, runStop } from "./hook.js";
8
+ import { loadPlanSpec, computeSpecHash, addSpecCase, readSpecCases, clearSpec } from "./spec.js";
9
+ import { loadState, resetGate } from "./state.js";
10
+ import { addDefer, loadDefers, resolveDefer } from "./defer.js";
11
+ import { selectedTransport } from "./judge.js";
12
+ import { buildPreCommand, upgradeKeylessHooks } from "./install.js";
13
+ const CLI_PATH = fileURLToPath(import.meta.url);
14
+ const PKG_ROOT = join(dirname(CLI_PATH), ".."); // dist/cli.js → 패키지 루트
15
+ /** ~/.gbc/api-key 존재 여부 — 있으면 hook에 키 주입(빠른 haiku 경로). */
16
+ function hasApiKey() {
17
+ return existsSync(join(homedir(), ".gbc", "api-key"));
18
+ }
19
+ function stopCommand() {
20
+ return `node "${CLI_PATH}" hook stop`;
21
+ }
22
+ function nowStamp() {
23
+ try {
24
+ return new Date().toISOString().replace(/[:.]/g, "-");
25
+ }
26
+ catch {
27
+ return "backup";
28
+ }
29
+ }
30
+ // ---------- gbc init ----------
31
+ function cmdInit(args) {
32
+ const cwd = process.cwd();
33
+ const yes = args.includes("--yes") || args.includes("-y");
34
+ const claudeDir = join(cwd, ".claude");
35
+ const settingsPath = join(claudeDir, "settings.json");
36
+ const skillDestDir = join(claudeDir, "skills", "gate");
37
+ const skillSrc = join(PKG_ROOT, "skills", "gate", "SKILL.md");
38
+ if (!yes) {
39
+ console.log(`🐢 gbc init — 다음을 수행합니다 (프로젝트 로컬만, 전역 ~/.claude 미변경):
40
+
41
+ 대상 프로젝트: ${cwd}
42
+ 1) ${settingsPath} 에 PreToolUse(Edit|Write) + Stop hook 추가 (머지·멱등)
43
+ - 기존 settings.json 있으면 백업: settings.json.bak-<시각>
44
+ 2) ${join(skillDestDir, "SKILL.md")} 에 /gate 스킬 설치
45
+ 3) hook 명령: ${buildPreCommand(CLI_PATH, hasApiKey())}
46
+
47
+ 실행하려면: gbc init --yes
48
+ `);
49
+ return;
50
+ }
51
+ mkdirSync(skillDestDir, { recursive: true });
52
+ // settings.json 머지
53
+ let settings = {};
54
+ if (existsSync(settingsPath)) {
55
+ const backup = `${settingsPath}.bak-${nowStamp()}`;
56
+ copyFileSync(settingsPath, backup);
57
+ console.log(` 백업: ${backup}`);
58
+ try {
59
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
60
+ }
61
+ catch {
62
+ console.error(` ⚠️ 기존 settings.json 파싱 실패 — 중단(수동 확인 필요). 백업은 보존됨.`);
63
+ process.exit(1);
64
+ }
65
+ }
66
+ const hooks = (settings.hooks ??= {});
67
+ const serialized = JSON.stringify(settings);
68
+ const useKey = hasApiKey();
69
+ // PreToolUse (멱등). 신규면 추가, 이미 있으면 keyless→키주입 업그레이드(skip만 하지 않음).
70
+ if (!serialized.includes("hook pre-tool-use")) {
71
+ (hooks.PreToolUse ??= []).push({
72
+ matcher: "Edit|Write|MultiEdit",
73
+ hooks: [{ type: "command", command: buildPreCommand(CLI_PATH, useKey) }],
74
+ });
75
+ console.log(` + PreToolUse hook 추가${useKey ? " (API 키 주입)" : ""}`);
76
+ }
77
+ else {
78
+ const n = useKey ? upgradeKeylessHooks(settings, CLI_PATH, true) : 0;
79
+ if (n > 0)
80
+ console.log(` ↑ PreToolUse hook 키주입 업그레이드 (${n}건)`);
81
+ else
82
+ console.log(` = PreToolUse hook 이미 존재 (skip)`);
83
+ }
84
+ // Stop (멱등)
85
+ if (!serialized.includes("hook stop")) {
86
+ (hooks.Stop ??= []).push({
87
+ hooks: [{ type: "command", command: stopCommand() }],
88
+ });
89
+ console.log(` + Stop hook 추가`);
90
+ }
91
+ else {
92
+ console.log(` = Stop hook 이미 존재 (skip)`);
93
+ }
94
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
95
+ // /gate 스킬 설치
96
+ if (existsSync(skillSrc)) {
97
+ copyFileSync(skillSrc, join(skillDestDir, "SKILL.md"));
98
+ console.log(` + /gate 스킬 설치`);
99
+ }
100
+ const transport = selectedTransport();
101
+ console.log(`
102
+ ✅ 설치 완료. 트랜스포트: ${transport}${transport === "cli"
103
+ ? " (ANTHROPIC_API_KEY 설정 시 직접 API로 ~1–3s, 미설정 시 claude -p 폴백 ~13–20s)"
104
+ : ""}
105
+ 계획 명세는 scratch.md 또는 .gbc/spec.md 에 작성하세요(없으면 시나리오 미지정으로 차단).`);
106
+ }
107
+ // ---------- gbc status ----------
108
+ function cmdStatus() {
109
+ const cwd = process.cwd();
110
+ const { text, source } = loadPlanSpec(cwd);
111
+ const hash = computeSpecHash(text);
112
+ const state = loadState(cwd);
113
+ const defers = loadDefers(cwd);
114
+ const unresolved = defers.filter((d) => !d.resolved);
115
+ console.log(`🐢 거북이 게이트 상태 — ${cwd}
116
+ 트랜스포트: ${selectedTransport()}
117
+ 명세 소스: ${source} ${text ? `(${text.length}자)` : "(비어있음 → 모든 코드변경 차단)"}
118
+ 명세 해시: ${hash}
119
+ 작업단위 게이트: ${state && state.specHash === hash && state.gated ? "통과됨(이 단위 재게이트 안 함)" : "미통과(다음 편집에서 발동)"}
120
+ defer: 전체 ${defers.length} / 미해결 ${unresolved.length}`);
121
+ if (unresolved.length > 0) {
122
+ console.log(unresolved.map((d, i) => ` ${i + 1}. ${d.item}`).join("\n"));
123
+ }
124
+ }
125
+ // ---------- gbc defer ----------
126
+ function cmdDefer(args) {
127
+ const cwd = process.cwd();
128
+ const sub = args[0];
129
+ if (sub === "add") {
130
+ const item = args.slice(1).join(" ").trim();
131
+ if (!item) {
132
+ console.error('사용: gbc defer add "<케이스 설명>"');
133
+ process.exit(1);
134
+ }
135
+ addDefer(cwd, item);
136
+ console.log(`🐢 미룸 등록: ${item}`);
137
+ }
138
+ else if (sub === "list") {
139
+ const defers = loadDefers(cwd);
140
+ if (defers.length === 0) {
141
+ console.log("(미룬 항목 없음)");
142
+ return;
143
+ }
144
+ defers.forEach((d, i) => console.log(`${i + 1}. [${d.resolved ? "해결" : "미해결"}] ${d.item}`));
145
+ }
146
+ else if (sub === "resolve") {
147
+ const ref = args.slice(1).join(" ").trim();
148
+ const r = resolveDefer(cwd, ref);
149
+ console.log(r ? `🐢 해결 표시: ${r.item}` : `매칭되는 미룬 항목 없음: ${ref}`);
150
+ }
151
+ else {
152
+ console.error("사용: gbc defer <add|list|resolve> ...");
153
+ process.exit(1);
154
+ }
155
+ }
156
+ // ---------- gbc spec ----------
157
+ function cmdSpec(args) {
158
+ const cwd = process.cwd();
159
+ const sub = args[0];
160
+ if (sub === "add") {
161
+ const item = args.slice(1).join(" ").trim();
162
+ if (!item) {
163
+ console.error('사용: gbc spec add "<케이스/시나리오>"');
164
+ process.exit(1);
165
+ }
166
+ addSpecCase(cwd, item);
167
+ console.log(`🐢 명세 등록: ${item}`);
168
+ }
169
+ else if (sub === "show") {
170
+ const cases = readSpecCases(cwd);
171
+ if (cases.length === 0) {
172
+ console.log("(등록된 케이스 없음 — .gbc/spec.md 비어있음)");
173
+ return;
174
+ }
175
+ cases.forEach((c, i) => console.log(`${i + 1}. ${c}`));
176
+ }
177
+ else if (sub === "clear") {
178
+ clearSpec(cwd);
179
+ console.log("🐢 명세 비움 — 다음 작업단위로 깨끗이 넘어갑니다.");
180
+ }
181
+ else {
182
+ console.error("사용: gbc spec <add|show|clear> ...");
183
+ process.exit(1);
184
+ }
185
+ }
186
+ // ---------- gbc gate ----------
187
+ function cmdGate(args) {
188
+ if (args[0] === "reset") {
189
+ resetGate(process.cwd());
190
+ console.log("🐢 작업단위 게이트 리셋 — 다음 편집에서 다시 발동합니다.");
191
+ }
192
+ else {
193
+ console.error("사용: gbc gate reset");
194
+ process.exit(1);
195
+ }
196
+ }
197
+ function usage() {
198
+ console.log(`🐢 gbc — 거북이코드 구현-전 게이트
199
+
200
+ 사용:
201
+ gbc init [--yes] 프로젝트에 hook + /gate 스킬 설치
202
+ gbc status 게이트 상태 + 로드된 명세 확인
203
+ gbc defer add "<케이스>" 케이스를 명시적으로 미루기
204
+ gbc defer list 미룬 항목 목록
205
+ gbc defer resolve <번호|텍스트> 미룬 항목 해결
206
+ gbc spec add "<케이스>" 승인된 시나리오를 .gbc/spec.md에 등록
207
+ gbc spec show 등록된 케이스 목록
208
+ gbc spec clear 명세 비우기(작업단위 종료)
209
+ gbc gate reset 작업단위 게이트 리셋
210
+ gbc hook pre-tool-use (내부) PreToolUse hook
211
+ gbc hook stop (내부) Stop hook
212
+ `);
213
+ }
214
+ async function main() {
215
+ const [cmd, ...rest] = process.argv.slice(2);
216
+ switch (cmd) {
217
+ case "hook":
218
+ if (rest[0] === "pre-tool-use")
219
+ return runPreToolUse();
220
+ if (rest[0] === "stop")
221
+ return runStop();
222
+ console.error("사용: gbc hook <pre-tool-use|stop>");
223
+ process.exit(1);
224
+ break;
225
+ case "init":
226
+ return cmdInit(rest);
227
+ case "status":
228
+ return cmdStatus();
229
+ case "defer":
230
+ return cmdDefer(rest);
231
+ case "spec":
232
+ return cmdSpec(rest);
233
+ case "gate":
234
+ return cmdGate(rest);
235
+ case undefined:
236
+ case "help":
237
+ case "--help":
238
+ case "-h":
239
+ return usage();
240
+ default:
241
+ console.error(`알 수 없는 명령: ${cmd}`);
242
+ usage();
243
+ process.exit(1);
244
+ }
245
+ }
246
+ main().catch((e) => {
247
+ console.error(`gbc 오류: ${String(e)}`);
248
+ process.exit(1);
249
+ });
package/dist/defer.js ADDED
@@ -0,0 +1,54 @@
1
+ import { join } from "node:path";
2
+ import { gbcDir, readJson, writeJson } from "./store.js";
3
+ function deferPath(cwd) {
4
+ return join(gbcDir(cwd), "defers.json");
5
+ }
6
+ function nowIso() {
7
+ try {
8
+ return new Date().toISOString();
9
+ }
10
+ catch {
11
+ return "";
12
+ }
13
+ }
14
+ export function loadDefers(cwd) {
15
+ return readJson(deferPath(cwd), []);
16
+ }
17
+ function save(cwd, defers) {
18
+ writeJson(deferPath(cwd), defers);
19
+ }
20
+ /** 명시적으로 항목을 미룬다 (침묵 누락 차단의 유일한 정당 경로) */
21
+ export function addDefer(cwd, item) {
22
+ const defers = loadDefers(cwd);
23
+ const entry = { item, at: nowIso(), resolved: false };
24
+ defers.push(entry);
25
+ save(cwd, defers);
26
+ return entry;
27
+ }
28
+ /** 미해결 defer 항목 텍스트만 (게이트 판정 입력용) */
29
+ export function activeDeferItems(cwd) {
30
+ return loadDefers(cwd)
31
+ .filter((d) => !d.resolved)
32
+ .map((d) => d.item);
33
+ }
34
+ /** 미해결 defer 엔트리 (Stop hook 리마인드용) */
35
+ export function unresolvedDefers(cwd) {
36
+ return loadDefers(cwd).filter((d) => !d.resolved);
37
+ }
38
+ /** 인덱스(1-base) 또는 부분 텍스트 매칭으로 defer 해결 표시 */
39
+ export function resolveDefer(cwd, ref) {
40
+ const defers = loadDefers(cwd);
41
+ let target;
42
+ const idx = Number.parseInt(ref, 10);
43
+ if (!Number.isNaN(idx) && idx >= 1 && idx <= defers.length) {
44
+ target = defers[idx - 1];
45
+ }
46
+ else {
47
+ target = defers.find((d) => !d.resolved && d.item.includes(ref));
48
+ }
49
+ if (!target)
50
+ return null;
51
+ target.resolved = true;
52
+ save(cwd, defers);
53
+ return target;
54
+ }
@@ -0,0 +1,36 @@
1
+ // 회귀 하네스: gate-spike의 cases.json을 production judge로 돌려 판정 품질을 확인한다.
2
+ // 트랜스포트는 judge가 자동 선택(ANTHROPIC_API_KEY 있으면 API, 없으면 claude -p).
3
+ // 사용: node dist/eval/regression.js [cases.json 경로]
4
+ import { readFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, join } from "node:path";
7
+ import { judge, selectedTransport } from "../judge.js";
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const casesPath = process.argv[2] ?? join(here, "..", "..", "test", "cases.json");
10
+ const cases = JSON.parse(readFileSync(casesPath, "utf8"));
11
+ console.log(`트랜스포트: ${selectedTransport()} · 케이스 ${cases.length}개 · 파일 ${casesPath}\n`);
12
+ const results = [];
13
+ for (const c of cases) {
14
+ const t0 = Date.now();
15
+ // 회귀는 defer 없는 기본 상태에서의 판정 품질을 본다.
16
+ const v = await judge(c.plan_spec, c.edit_diff, []);
17
+ const ms = Date.now() - t0;
18
+ const ok = v.verdict === c.expected;
19
+ results.push({ id: c.id, expected: c.expected, got: v.verdict, ok, ms, reason: v.reason });
20
+ console.log(`${ok ? "✓" : "✗"} ${c.id}: exp=${c.expected} got=${v.verdict} (${ms}ms) — ${v.reason}`);
21
+ }
22
+ const tp = results.filter((r) => r.expected === "block" && r.got === "block").length;
23
+ const tn = results.filter((r) => r.expected === "pass" && r.got === "pass").length;
24
+ const fp = results.filter((r) => r.expected === "pass" && r.got === "block").length;
25
+ const fn = results.filter((r) => r.expected === "block" && r.got === "pass").length;
26
+ const avg = Math.round(results.reduce((s, r) => s + r.ms, 0) / results.length);
27
+ const pass = tp + tn;
28
+ console.log(`\n===== 결과 =====`);
29
+ console.log(`TP=${tp}(누락차단) TN=${tn}(정상통과) FP=${fp}(오탐) FN=${fn}(미탐)`);
30
+ console.log(`정확도 ${pass}/${results.length} · 평균지연 ${avg}ms`);
31
+ // 회귀 기준: 8/8 (스파이크 baseline). 미달 시 비정상 종료로 신호.
32
+ if (pass < results.length) {
33
+ console.error(`\n⚠️ 회귀 실패: ${pass}/${results.length} (baseline 8/8 미달)`);
34
+ process.exit(1);
35
+ }
36
+ console.log(`\n✅ 회귀 통과: baseline 유지`);
package/dist/hook.js ADDED
@@ -0,0 +1,159 @@
1
+ // PreToolUse / Stop hook 핸들러.
2
+ // 핫패스 보호: 이 파일은 SDK를 import하지 않는다. judge.ts가 API 호출 시에만 lazy import.
3
+ // "이미 게이트됨 → exit 0"은 상태파일만 읽고 즉시 종료(judge 미호출).
4
+ import { isGatedTool, normalizeEdit } from "./normalize.js";
5
+ import { loadPlanSpec, computeSpecHash } from "./spec.js";
6
+ import { isGated, markGated } from "./state.js";
7
+ import { activeDeferItems, unresolvedDefers, loadDefers } from "./defer.js";
8
+ import { appendFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { gbcDir } from "./store.js";
11
+ /**
12
+ * 차단 사유 메시지를 빌드한다. 두 차단 종류를 다르게 안내한다:
13
+ * - specEmpty=true (시나리오 미지정): 에이전트가 요청에서 시나리오를 도출 → 사용자 검증 →
14
+ * 'gbc spec add'로 등록 후 재시도하도록 지시한다(도출 루프 트리거). 자동 등록 금지.
15
+ * - specEmpty=false (침묵 누락): 지금 다루거나 'gbc defer add'로 명시 미루도록 안내한다.
16
+ */
17
+ export function buildBlockReason(verdict, specEmpty, source) {
18
+ if (specEmpty) {
19
+ return (`🐢 거북이 게이트 — ${verdict.reason}\n` +
20
+ `→ [에이전트] 사용자 요청에서 의도·동작 시나리오를 도출해 사용자에게 제시·검증받은 뒤, ` +
21
+ `승인된 케이스를 'gbc spec add "<케이스>"'로 등록하고 재시도하세요. ` +
22
+ `사용자 승인 없이 자동 등록하지 마세요. (명세 소스: ${source})`);
23
+ }
24
+ const missingLine = verdict.missing.length > 0 ? `\n누락(침묵): ${verdict.missing.join(", ")}` : "";
25
+ return (`🐢 거북이 게이트 — ${verdict.reason}${missingLine}\n` +
26
+ `→ 지금 이 변경에서 다루거나, 의도적으로 미룰 거면 'gbc defer add "<케이스>"'로 명시 등록 후 진행하세요.` +
27
+ ` (명세 소스: ${source})`);
28
+ }
29
+ /**
30
+ * pass verdict를 작업단위 캐시(markGated)에 넣어도 되는가.
31
+ * fail-open(판정 실패 안전통과)은 제외 — 일시 장애가 작업단위 내내 게이트를 무력화하는 것을 막는다.
32
+ */
33
+ export function shouldCacheVerdict(verdict) {
34
+ return verdict.verdict === "pass" && !verdict.failOpen;
35
+ }
36
+ function readStdin() {
37
+ return new Promise((resolve) => {
38
+ let data = "";
39
+ process.stdin.setEncoding("utf8");
40
+ process.stdin.on("data", (c) => (data += c));
41
+ process.stdin.on("end", () => resolve(data));
42
+ process.stdin.on("error", () => resolve(data));
43
+ // stdin이 비어있는 경우 대비
44
+ if (process.stdin.isTTY)
45
+ resolve("");
46
+ });
47
+ }
48
+ function emit(obj) {
49
+ process.stdout.write(JSON.stringify(obj));
50
+ }
51
+ function logBypass(cwd, toolName) {
52
+ try {
53
+ appendFileSync(join(gbcDir(cwd), "bypass.log"), `${new Date().toISOString()} ${toolName}\n`);
54
+ }
55
+ catch {
56
+ /* 계측 실패는 무시 */
57
+ }
58
+ }
59
+ /** fail-open(판정 실패 안전통과) 계측 — 게이트가 무력화된 편집을 사후 추적할 수 있게 한다. */
60
+ function logFailOpen(cwd, toolName, reason) {
61
+ try {
62
+ appendFileSync(join(gbcDir(cwd), "failopen.log"), `${new Date().toISOString()} ${toolName} ${reason}\n`);
63
+ }
64
+ catch {
65
+ /* 계측 실패는 무시 */
66
+ }
67
+ }
68
+ /** PreToolUse: 코드 변경 직전 게이트 */
69
+ export async function runPreToolUse() {
70
+ let input = {};
71
+ try {
72
+ const raw = await readStdin();
73
+ input = raw ? JSON.parse(raw) : {};
74
+ }
75
+ catch {
76
+ // 입력 파싱 실패 → 안전하게 통과(fail-open)
77
+ process.exit(0);
78
+ }
79
+ const toolName = input.tool_name ?? "";
80
+ const cwd = input.cwd || process.cwd();
81
+ // 코드 변경 도구가 아니면 즉시 통과
82
+ if (!isGatedTool(toolName))
83
+ process.exit(0);
84
+ // 명시적 우회 (계측됨)
85
+ if (process.env.GBC_NO_GATE === "1") {
86
+ logBypass(cwd, toolName);
87
+ process.exit(0);
88
+ }
89
+ const { text: specText, source } = loadPlanSpec(cwd);
90
+ const specHash = computeSpecHash(specText);
91
+ // 작업단위 1회: 이미 게이트 통과한 단위면 즉시 통과 (judge 미호출, 핫패스)
92
+ if (isGated(cwd, specHash))
93
+ process.exit(0);
94
+ // judge는 여기서만 동적 import (SDK lazy)
95
+ const { judge } = await import("./judge.js");
96
+ const editText = normalizeEdit(toolName, input.tool_input ?? {});
97
+ const defers = activeDeferItems(cwd);
98
+ const verdict = await judge(specText, editText, defers);
99
+ if (verdict.verdict === "pass") {
100
+ if (shouldCacheVerdict(verdict)) {
101
+ markGated(cwd, specHash, verdict.reason);
102
+ process.exit(0); // 정상 통과 (자동승인 X — 무출력)
103
+ }
104
+ // fail-open: 판정 실패로 안전 통과. 캐시하지 않아(작업단위 무력화 방지) 다음 편집에서 재판정.
105
+ // 계측 + systemMessage로 사용자에게 "게이트가 검사 못 했음"을 알린다(조용한 무력화 방지).
106
+ logFailOpen(cwd, toolName, verdict.reason);
107
+ emit({
108
+ systemMessage: `🐢 거북이 게이트 — 판정 실패로 안전 통과(fail-open). 이 편집은 게이트 검사를 받지 못했습니다: ${verdict.reason}`,
109
+ hookSpecificOutput: {
110
+ hookEventName: "PreToolUse",
111
+ permissionDecision: "allow",
112
+ permissionDecisionReason: verdict.reason,
113
+ },
114
+ });
115
+ process.exit(0);
116
+ }
117
+ // block: 사람 pause (ask 기본) — 사유가 사용자에게 표시됨
118
+ // 시나리오 미지정(명세 빈약)과 침묵 누락을 다르게 안내한다.
119
+ const reason = buildBlockReason(verdict, specText.trim() === "", source);
120
+ const mode = process.env.GBC_BLOCK_MODE === "deny" ? "deny" : "ask";
121
+ emit({
122
+ hookSpecificOutput: {
123
+ hookEventName: "PreToolUse",
124
+ permissionDecision: mode,
125
+ permissionDecisionReason: reason,
126
+ additionalContext: reason,
127
+ },
128
+ });
129
+ process.exit(0);
130
+ }
131
+ /** Stop: 미해결 defer 리마인드 (stop_hook_active 가드로 1회만) */
132
+ export async function runStop() {
133
+ let input = {};
134
+ try {
135
+ const raw = await readStdin();
136
+ input = raw ? JSON.parse(raw) : {};
137
+ }
138
+ catch {
139
+ process.exit(0);
140
+ }
141
+ // 이미 한 번 리마인드해 계속 진행 중이면 루프 방지 위해 통과
142
+ if (input.stop_hook_active === true)
143
+ process.exit(0);
144
+ const cwd = input.cwd || process.cwd();
145
+ // defers.json 없으면(파일 부재) 조용히 통과
146
+ if (loadDefers(cwd).length === 0)
147
+ process.exit(0);
148
+ const un = unresolvedDefers(cwd);
149
+ if (un.length === 0)
150
+ process.exit(0);
151
+ const items = un.map((d, i) => `${i + 1}. ${d.item}`).join("\n");
152
+ emit({
153
+ decision: "block",
154
+ reason: `🐢 미해결 defer ${un.length}건이 남아 있습니다:\n${items}\n` +
155
+ `해결했으면 'gbc defer resolve <번호>', 다음 세션으로 이월할 거면 의식적으로 확인하세요. ` +
156
+ `(이 리마인드는 1회만 표시됩니다.)`,
157
+ });
158
+ process.exit(0);
159
+ }
@@ -0,0 +1,39 @@
1
+ // gbc init 설치 로직 (순수함수 — cli.ts main() 부작용 없이 단위테스트 가능).
2
+ // 키 주입은 셸 확장($HOME)으로 머신 독립적이게 한다(하드코딩 홈경로 금지).
3
+ /**
4
+ * PreToolUse hook 명령 생성.
5
+ * useKey=true면 ANTHROPIC_API_KEY를 $HOME/.gbc/api-key에서 읽어 주입(빠른 haiku 경로).
6
+ * 경로는 셸이 확장하는 $HOME을 써 머신 독립적이게 한다.
7
+ */
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;
19
+ }
20
+ /**
21
+ * 이미 설치된 keyless PreToolUse hook command를 키 주입 버전으로 업그레이드.
22
+ * settings를 제자리 수정하고, 업그레이드한 건수를 반환한다(멱등: 이미 키주입된 건 건너뜀).
23
+ */
24
+ export function upgradeKeylessHooks(settings, cliPath, useKey) {
25
+ if (!useKey)
26
+ return 0;
27
+ const target = buildPreCommand(cliPath, true);
28
+ let upgraded = 0;
29
+ for (const entry of settings.hooks?.PreToolUse ?? []) {
30
+ 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")) {
33
+ h.command = target;
34
+ upgraded++;
35
+ }
36
+ }
37
+ }
38
+ return upgraded;
39
+ }
package/dist/judge.js ADDED
@@ -0,0 +1,113 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const MODEL = process.env.GBC_MODEL ?? "claude-haiku-4-5";
5
+ /**
6
+ * 최소 게이트 시스템 프롬프트.
7
+ * 의미론(핵심): 한 편집이 모든 케이스를 *완전 구현*할 필요는 없다.
8
+ * 침묵 누락(언급도 명시 defer도 없이 빠뜨림)과 시나리오 미지정만 차단한다.
9
+ */
10
+ const GATE_SYSTEM = `너는 코드 구현 직전에 동작하는 "게이트"다. 개발자가 막 파일을 편집하려 한다.
11
+ [계획 명세]·[현재 편집]·[명시적으로 미룬 항목]을 보고 통과(pass)/차단(block)을 판정하라.
12
+
13
+ [1단계 — 편집의 종류 분류]
14
+ 이 편집이 프로그램의 *동작(behavior)을 구현하거나 바꾸는* 코드 편집인가, 아니면 동작과 무관한 편집인가?
15
+ - 동작과 무관한 편집 → **무조건 pass.** 예: 문서/README 수정, 단순 변수·함수 리네임, 포매팅/들여쓰기, 주석만 추가, 동작 불변 리팩터, 설정/빌드 파일 변경.
16
+ (판별 팁: 함수 본문의 로직/검증/분기/반환을 새로 쓰거나 바꾸면 "동작 편집". 이름만 바꾸거나 글자만 옮기면 "무관".)
17
+ - 동작을 구현/변경하는 코드 편집 → 2단계로.
18
+
19
+ [2단계 — 동작 편집일 때만 검사]
20
+ (a) [계획 명세]가 없거나 빈약해서 이 동작의 의도·시나리오가 미지정인 채 구현되고 있는가? → **block** (시나리오 미지정).
21
+ (b) 계획 명세가 있다면: 이 편집이 작성/수정하는 *바로 그 기능*에 대해 계획에 적힌 형제 케이스 중, 이 편집에서도 안 다뤄지고 [명시적으로 미룬 항목]에도 없는 것이 있는가? → **block** (침묵 누락).
22
+ - 예: 로그인 검증 함수를 쓰면서 계획의 로그인 검증 케이스(중복 이메일·비밀번호 길이 등)를 언급·등록 없이 빠뜨림.
23
+ - 코드 주석으로 "나중에"라고만 적고 미룬 항목에 등록 안 한 것도 침묵 누락이다.
24
+ - 계획이 요구한 동작 형태(예: 인라인 에러 메시지)를 충족 못 하고 다른 형태(예: bool만 반환)로 빠뜨린 것도 누락이다.
25
+ (c) 위에 해당 없으면 → **pass**.
26
+
27
+ 핵심 균형:
28
+ - 무관한 편집(1단계)을 "계획 케이스를 안 다뤘다"는 이유로 차단하지 마라(가장 흔한 오탐).
29
+ - 그러나 *같은 기능을 구현하는* 동작 편집이 계획된 형제 케이스를 침묵 누락하면 반드시 차단하라. "첫 케이스를 시작했다"는 이유로 나머지 침묵 누락을 눈감지 마라(가장 흔한 미탐). 한 편집이 모든 케이스를 *완전 구현*할 필요는 없지만, 형제 케이스는 최소한 다뤄지거나 명시 defer돼 있어야 한다.
30
+
31
+ 오직 아래 JSON만 출력(설명·마크다운 펜스 금지):
32
+ {"verdict":"block"|"pass","missing":["누락된 케이스"],"reason":"한 줄 사유"}`;
33
+ function buildUserMessage(planSpec, editText, defers) {
34
+ const deferText = defers.length > 0 ? defers.map((d) => `- ${d}`).join("\n") : "(없음)";
35
+ return `[계획 명세]
36
+ ${planSpec.trim() || "(계획 명세 없음 — 개발자가 곧바로 구현을 시작함)"}
37
+
38
+ [명시적으로 미룬 항목]
39
+ ${deferText}
40
+
41
+ [현재 편집]
42
+ ${editText}`;
43
+ }
44
+ function parseVerdict(raw) {
45
+ const m = raw.match(/\{[\s\S]*\}/);
46
+ if (!m)
47
+ throw new Error(`게이트 응답에서 JSON을 찾지 못함: ${raw.slice(0, 200)}`);
48
+ const j = JSON.parse(m[0]);
49
+ const verdict = j.verdict === "block" ? "block" : "pass";
50
+ return {
51
+ verdict,
52
+ missing: Array.isArray(j.missing) ? j.missing.map(String) : [],
53
+ reason: typeof j.reason === "string" ? j.reason : "",
54
+ };
55
+ }
56
+ /** 트랜스포트 선택 결과 (디버그/리포트용) */
57
+ export function selectedTransport() {
58
+ return process.env.ANTHROPIC_API_KEY ? "api" : "cli";
59
+ }
60
+ /** 직접 Anthropic API (haiku). SDK는 여기서만 lazy import → hook 핫패스 보호. */
61
+ async function judgeViaApi(system, user) {
62
+ const mod = await import("@anthropic-ai/sdk");
63
+ const Anthropic = mod.default;
64
+ const client = new Anthropic(); // ANTHROPIC_API_KEY 환경변수 사용
65
+ const resp = await client.messages.create({
66
+ model: MODEL,
67
+ max_tokens: 1024,
68
+ system,
69
+ messages: [{ role: "user", content: user }],
70
+ });
71
+ const texts = [];
72
+ for (const block of resp.content) {
73
+ if (block.type === "text")
74
+ texts.push(block.text);
75
+ }
76
+ return texts.join("");
77
+ }
78
+ /** claude -p 폴백 (무설정 도그푸딩용, 느림). CC의 기존 인증 사용. */
79
+ async function judgeViaCli(system, user) {
80
+ const { stdout } = await execFileAsync("claude", ["-p", user, "--append-system-prompt", system, "--model", MODEL, "--output-format", "json"], { maxBuffer: 10 * 1024 * 1024 });
81
+ const env = JSON.parse(stdout);
82
+ return env.result ?? "";
83
+ }
84
+ /**
85
+ * 게이트 판정. ANTHROPIC_API_KEY 있으면 직접 API(빠름), 없으면 claude -p 폴백.
86
+ * 실패 시 안전하게 pass(fail-open) — 게이트가 개발을 막아버리는 사고 방지.
87
+ */
88
+ export async function judge(planSpec, editText, defers = []) {
89
+ const user = buildUserMessage(planSpec, editText, defers);
90
+ const transport = selectedTransport();
91
+ try {
92
+ const raw = transport === "api"
93
+ ? await judgeViaApi(GATE_SYSTEM, user)
94
+ : await judgeViaCli(GATE_SYSTEM, user);
95
+ return parseVerdict(raw);
96
+ }
97
+ catch (e) {
98
+ return failOpenVerdict(e);
99
+ }
100
+ }
101
+ /**
102
+ * 판정 호출 실패 시의 안전 통과(fail-open) verdict.
103
+ * failOpen=true로 표시해 hook이 캐시 제외·계측할 수 있게 한다.
104
+ */
105
+ export function failOpenVerdict(e) {
106
+ return {
107
+ verdict: "pass",
108
+ missing: [],
109
+ reason: `게이트 판정 실패(fail-open): ${String(e).slice(0, 160)}`,
110
+ failOpen: true,
111
+ };
112
+ }
113
+ export { GATE_SYSTEM, buildUserMessage, parseVerdict };
@@ -0,0 +1,28 @@
1
+ const MAX_FIELD = 4000; // 프롬프트 비대화/지연 방지용 필드 절단 길이
2
+ function clip(s) {
3
+ if (!s)
4
+ return "";
5
+ return s.length > MAX_FIELD ? s.slice(0, MAX_FIELD) + "\n…(절단됨)" : s;
6
+ }
7
+ /**
8
+ * PreToolUse tool_input(Edit/Write/MultiEdit)을 게이트 프롬프트용
9
+ * diff 유사 텍스트로 정규화한다. tool_name으로 분기.
10
+ */
11
+ export function normalizeEdit(toolName, input) {
12
+ const file = input.file_path ?? "(파일경로 없음)";
13
+ // Write: 파일 전체 생성/덮어쓰기
14
+ if (toolName === "Write" || (input.content !== undefined && !input.old_string && !input.edits)) {
15
+ return `--- ${file} (전체 작성/덮어쓰기)\n+ ${clip(input.content)}`;
16
+ }
17
+ // MultiEdit: edits 배열
18
+ if (toolName === "MultiEdit" || Array.isArray(input.edits)) {
19
+ const parts = (input.edits ?? []).map((e, i) => `# 편집 ${i + 1}\n- ${clip(e.old_string)}\n+ ${clip(e.new_string)}`);
20
+ return `--- ${file} (다중 편집)\n${parts.join("\n")}`;
21
+ }
22
+ // Edit: 단일 치환
23
+ return `--- ${file}\n- ${clip(input.old_string)}\n+ ${clip(input.new_string)}`;
24
+ }
25
+ /** 게이트 대상(코드 변경)인 도구인지 */
26
+ export function isGatedTool(toolName) {
27
+ return toolName === "Edit" || toolName === "Write" || toolName === "MultiEdit";
28
+ }
package/dist/spec.js ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync, writeFileSync, appendFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { gbcDir } from "./store.js";
5
+ const MAX_SPEC = 12000; // 명세 텍스트 절단 (프롬프트 비대화 방지)
6
+ /**
7
+ * 계획 명세를 디스크에서 로드한다. (advisor④: durable 소스만 — 라이브 SubTask는 영속 X)
8
+ * 우선순위: GBC_SPEC_FILE > .gbc/spec.md > scratch.md > "" (빈 명세 = 시나리오 미지정 → 통증#2 차단)
9
+ *
10
+ * 느슨 매칭은 게이트 LLM이 담당한다(체크리스트 라인/SubTask 항목). 로더는 텍스트만 제공.
11
+ */
12
+ export function loadPlanSpec(cwd) {
13
+ const candidates = [];
14
+ if (process.env.GBC_SPEC_FILE)
15
+ candidates.push(process.env.GBC_SPEC_FILE);
16
+ candidates.push(join(cwd, ".gbc", "spec.md"));
17
+ candidates.push(join(cwd, "scratch.md"));
18
+ for (const path of candidates) {
19
+ if (existsSync(path)) {
20
+ try {
21
+ const raw = readFileSync(path, "utf8");
22
+ const text = raw.length > MAX_SPEC ? raw.slice(0, MAX_SPEC) + "\n…(절단됨)" : raw;
23
+ return { text, source: path };
24
+ }
25
+ catch {
26
+ // 읽기 실패는 다음 후보로
27
+ }
28
+ }
29
+ }
30
+ return { text: "", source: "(없음)" };
31
+ }
32
+ /** 명세 해시 — 작업단위 식별용. 명세가 바뀌면 새 작업단위로 간주해 재게이트. */
33
+ export function computeSpecHash(text) {
34
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
35
+ }
36
+ // --- spec.md 쓰기 (gbc spec 서브커맨드 백엔드) ---
37
+ // 도출→검증→등록 루프에서, 사용자 승인된 시나리오를 durable 명세로 기록한다.
38
+ // 주 경로는 에이전트가 .gbc/spec.md를 직접 작성하는 것이고, 이 CLI는 한 줄 케이스 추가용 보조.
39
+ const MAX_CASE = 500; // 한 케이스 길이 상한 (spec.md 비대화·무제한 기록 방지)
40
+ function specPath(cwd) {
41
+ return join(gbcDir(cwd), "spec.md");
42
+ }
43
+ /**
44
+ * 케이스 한 줄을 .gbc/spec.md에 append. 파일 없으면 헤더와 함께 생성.
45
+ * 입력은 한 줄로 정규화한다: 줄바꿈→공백(readSpecCases 단일라인 매칭과 정합),
46
+ * 길이 상한 절단(에이전트가 멀티라인/장문 출력을 그대로 add해도 안전).
47
+ */
48
+ export function addSpecCase(cwd, item) {
49
+ const path = specPath(cwd);
50
+ const normalized = item.trim().replace(/\s*\n+\s*/g, " ").slice(0, MAX_CASE);
51
+ const line = `- [ ] ${normalized}\n`;
52
+ if (existsSync(path)) {
53
+ appendFileSync(path, line, "utf8");
54
+ }
55
+ else {
56
+ writeFileSync(path, `# 작업 명세\n\n${line}`, "utf8");
57
+ }
58
+ }
59
+ /** 현재 spec.md의 케이스(체크리스트 라인) 텍스트만 추출. */
60
+ export function readSpecCases(cwd) {
61
+ const path = specPath(cwd);
62
+ if (!existsSync(path))
63
+ return [];
64
+ const cases = [];
65
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
66
+ const m = raw.match(/^\s*-\s*\[[ xX]\]\s*(.+?)\s*$/);
67
+ if (m)
68
+ cases.push(m[1]);
69
+ }
70
+ return cases;
71
+ }
72
+ /** 작업단위 완료 시 spec.md를 비운다 (다음 작업단위로 깨끗이 넘어가기). */
73
+ export function clearSpec(cwd) {
74
+ writeFileSync(specPath(cwd), "", "utf8");
75
+ }
package/dist/state.js ADDED
@@ -0,0 +1,41 @@
1
+ import { join } from "node:path";
2
+ import { gbcDir, readJson, writeJson } from "./store.js";
3
+ function statePath(cwd) {
4
+ return join(gbcDir(cwd), "state.json");
5
+ }
6
+ export function loadState(cwd) {
7
+ return readJson(statePath(cwd), null);
8
+ }
9
+ function nowIso() {
10
+ // Date.now/new Date()는 일부 실행환경에서 금지될 수 있어 안전하게 처리
11
+ try {
12
+ return new Date().toISOString();
13
+ }
14
+ catch {
15
+ return "";
16
+ }
17
+ }
18
+ /**
19
+ * 이 작업단위(specHash)가 이미 게이트를 통과했는가?
20
+ * 명세가 바뀌면(specHash 불일치) 새 작업단위 → 미게이트로 간주.
21
+ */
22
+ export function isGated(cwd, specHash) {
23
+ const s = loadState(cwd);
24
+ return !!s && s.specHash === specHash && s.gated;
25
+ }
26
+ /** 이 작업단위를 게이트 통과로 표시 (작업단위 1회 캐시) */
27
+ export function markGated(cwd, specHash, reason) {
28
+ const state = { specHash, gated: true, lastReason: reason, at: nowIso() };
29
+ writeJson(statePath(cwd), state);
30
+ }
31
+ /** 작업단위 리셋 — 다음 편집에서 다시 게이트 발동 */
32
+ export function resetGate(cwd) {
33
+ const s = loadState(cwd);
34
+ const state = {
35
+ specHash: s?.specHash ?? "",
36
+ gated: false,
37
+ lastReason: "수동 리셋",
38
+ at: nowIso(),
39
+ };
40
+ writeJson(statePath(cwd), state);
41
+ }
package/dist/store.js ADDED
@@ -0,0 +1,22 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** .gbc 디렉토리 경로 보장 */
4
+ export function gbcDir(cwd) {
5
+ const dir = join(cwd, ".gbc");
6
+ if (!existsSync(dir))
7
+ mkdirSync(dir, { recursive: true });
8
+ return dir;
9
+ }
10
+ export function readJson(path, fallback) {
11
+ if (!existsSync(path))
12
+ return fallback;
13
+ try {
14
+ return JSON.parse(readFileSync(path, "utf8"));
15
+ }
16
+ catch {
17
+ return fallback;
18
+ }
19
+ }
20
+ export function writeJson(path, data) {
21
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
22
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // 거북이코드 게이트 공통 타입
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "geobuke-code",
3
+ "version": "0.1.0",
4
+ "description": "거북이코드 — 구현 직전 강제 게이트. Claude Code PreToolUse hook으로 코드 변경 전 계획 케이스 누락·시나리오 미지정을 차단한다.",
5
+ "type": "module",
6
+ "bin": {
7
+ "gbc": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "skills"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "test": "node --test 'test/**/*.test.mjs'",
19
+ "eval": "node dist/eval/regression.js",
20
+ "prepare": "tsc",
21
+ "prepublishOnly": "npm run build && npm test"
22
+ },
23
+ "keywords": [
24
+ "claude-code",
25
+ "hook",
26
+ "gate",
27
+ "pre-implementation",
28
+ "code-review"
29
+ ],
30
+ "license": "MIT",
31
+ "author": "cubha",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/cubha/geobuke-code.git"
35
+ },
36
+ "dependencies": {
37
+ "@anthropic-ai/sdk": "^0.40.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "typescript": "^5.6.0"
42
+ }
43
+ }
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: gate
3
+ description: 거북이코드 구현-전 게이트를 관리한다. 로드된 계획 명세 확인, 미룬(defer) 항목 등록·조회·해결, 작업단위 게이트 리셋. PreToolUse hook이 코드 변경을 차단했을 때 이 스킬로 케이스를 명시적으로 미루거나 게이트 상태를 점검한다. '/gate', '게이트 상태', '케이스 미루기', 'defer 등록', '게이트 리셋', '게이트가 막아' 등 언급 시 호출.
4
+ ---
5
+
6
+ # /gate — 구현-전 게이트 관리
7
+
8
+ 거북이코드(`gbc`)는 코드 변경(Edit/Write/MultiEdit) 직전에 PreToolUse hook으로 동작해, **계획 명세의 케이스를 침묵 누락하거나 시나리오 미지정으로 구현이 진행되는 것**을 차단한다. 이 스킬은 그 게이트를 사람이 관리하는 표면이다.
9
+
10
+ ## 핵심 원칙
11
+
12
+ - **미루기는 명시 등록만 허용한다.** "추후작업"이라고 머릿속/주석으로만 미루면 게이트가 침묵 누락으로 차단한다. 정당한 미루기는 반드시 `gbc defer add`로 등록해야 통과된다. (= 통증 "추후작업 미루다 누락" 직격)
13
+ - **게이트는 완전구현을 요구하지 않는다.** 케이스가 다뤄지기 시작했거나 명시 defer되면 통과. 침묵 누락과 시나리오 미지정만 막는다.
14
+
15
+ ## 명령 (bash로 실행)
16
+
17
+ 게이트는 현재 프로젝트 루트의 `gbc`를 사용한다. 작업 디렉토리에서 실행:
18
+
19
+ | 의도 | 명령 |
20
+ |---|---|
21
+ | 게이트 상태·로드된 명세 확인 | `gbc status` |
22
+ | 미룬 항목 목록 | `gbc defer list` |
23
+ | 케이스를 명시적으로 미루기 | `gbc defer add "<케이스 설명>"` |
24
+ | 미룬 항목 해결 표시 | `gbc defer resolve <번호 또는 텍스트>` |
25
+ | 승인된 시나리오를 명세에 등록 | `gbc spec add "<케이스>"` |
26
+ | 등록된 케이스 목록 | `gbc spec show` |
27
+ | 명세 비우기(작업단위 종료) | `gbc spec clear` |
28
+ | 작업단위 게이트 리셋(다음 편집에서 재발동) | `gbc gate reset` |
29
+
30
+ ## 사용 흐름
31
+
32
+ 1. **게이트가 변경을 차단했을 때**: hook이 사유와 누락 케이스를 알려준다. 두 갈래로 해소한다:
33
+ - (a) 누락 케이스를 **지금 이 변경에서 다룬다** → 다시 시도하면 통과.
34
+ - (b) 의도적으로 나중에 할 케이스면 **`gbc defer add "케이스"`로 명시 등록** → 통과. (절대 주석으로만 미루지 말 것)
35
+ 2. **시나리오 미지정으로 차단됐을 때 — 에이전트 도출 루프**: 사용자가 명세를 수기로 쓰지 않는다. 에이전트가 다음을 수행한다:
36
+ 1. 사용자 요청에서 의도·동작 시나리오와 형제 케이스를 **도출**한다.
37
+ 2. 도출한 케이스를 사용자에게 **제시하고 검증받는다** — **사용자 승인 없이 자동 등록·구현 금지**(같은 에이전트가 도출+구현하면 고무도장이 됨).
38
+ 3. 승인된 케이스를 `gbc spec add "<케이스>"`로 등록하거나 `.gbc/spec.md`에 직접 작성한다.
39
+ 4. 재시도하면 통과한다.
40
+ > 시나리오 도출은 코딩 에이전트 본체(Opus)가 대화 맥락으로, 게이트 판정은 haiku가 — 두 작업/두 모델 분리(gbc는 모델 계층을 소유하지 않는다).
41
+ 3. **세션 종료 시**: Stop hook이 미해결 defer를 리마인드한다. `gbc defer list`로 확인하고 해결하거나 다음 세션으로 의식적으로 이월한다.
42
+
43
+ ## 명세 소스
44
+
45
+ 게이트는 다음 우선순위로 계획 명세를 읽는다(durable 소스만):
46
+ `$GBC_SPEC_FILE` > `.gbc/spec.md` > `scratch.md`
47
+
48
+ 명세가 비면 "시나리오 미지정"으로 모든 코드 변경이 차단된다. 작업 전 `scratch.md`에 SubTask/체크리스트를 적거나 `.gbc/spec.md`를 만든다.
49
+
50
+ ## Known Pitfalls
51
+
52
+ - **주석 defer는 defer가 아니다.** `// 비밀번호 검증은 다음에` 같은 코드 주석은 게이트가 침묵 누락으로 본다. 반드시 `gbc defer add`로 레지스트리에 등록해야 한다.
53
+ - **게이트는 작업단위당 1회만 발동한다.** 명세가 바뀌거나 명세 밖 파일을 편집할 때 재발동한다. 강제로 다시 점검하려면 `gbc gate reset`.
54
+ - **`--no-gate` / `GBC_NO_GATE=1` 우회는 계측된다.** 우회 자체가 게이트 가치 측정 데이터가 된다.