geobuke-code 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli.js +15 -5
- package/dist/hook.js +65 -24
- package/dist/install.js +23 -0
- package/dist/spec.js +5 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ npm install -g geobuke-code
|
|
|
27
27
|
|
|
28
28
|
# 2) 대상 프로젝트에 게이트 설치
|
|
29
29
|
cd <your-project>
|
|
30
|
-
gbc init # .claude/settings.json에 hook + /gate skill 머지 (동의·백업)
|
|
30
|
+
gbc init # .claude/settings.json에 hook(PreToolUse+Stop+SessionStart) + /gate skill 머지 (동의·백업)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
<details>
|
|
@@ -84,11 +84,23 @@ Set-Content -Path "$HOME\.gbc\api-key" -Value "sk-ant-..." -NoNewline
|
|
|
84
84
|
phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이스확정】 → 구현(Claude Code) → 검증
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
게이트는 계획 명세를
|
|
88
|
-
`$GBC_SPEC_FILE` > `.gbc/spec.md` > `scratch.md`
|
|
87
|
+
게이트는 계획 명세를 `.gbc/spec.md`(단일 정본)에서 읽는다. 다른 파일을 명세로 쓰려면 `$GBC_SPEC_FILE` 환경변수로 그 경로를 명시 지정한다(우선순위 `$GBC_SPEC_FILE` > `.gbc/spec.md`). gbc가 소유하지 않은 파일을 자동 폴백하지 않으므로, 진행추적 파일 등이 명세로 오인되지 않는다.
|
|
89
88
|
|
|
90
89
|
코드 변경 직전 PreToolUse hook이 명세 ↔ 변경 ↔ 미룬 항목을 대조해 통과/차단을 판정한다.
|
|
91
90
|
|
|
91
|
+
### 동작 시점
|
|
92
|
+
|
|
93
|
+
`gbc init`이 프로젝트 `.claude/settings.json`에 아래 hook을 멱등 등록한다. gbc는 `.gbc/`만 읽으므로(다른 하네스의 메모리·진행추적 파일 미접근) 어떤 환경에서든 동일하게 동작한다.
|
|
94
|
+
|
|
95
|
+
| 시점 | hook (matcher) | 동작 |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| **세션 시작·재개** | SessionStart (`startup\|resume`) | `.gbc/defers.json`의 미해결 항목을 표면화(이전 작업 잔여 환기). 잔여 없으면 무출력. `compact`엔 발화 안 함(노이즈 방지) |
|
|
98
|
+
| **코드 변경 직전** | PreToolUse (`Edit\|Write\|MultiEdit`) | 명세 ↔ 변경 ↔ defer 대조 → 통과(침묵)/차단(시나리오 도출 지시)/fail-open |
|
|
99
|
+
| **작업단위당 1회** | (PreToolUse 캐시) | 같은 명세 해시 내에선 첫 편집만 판정, 이후 통과 → 매 편집 지연 회피 |
|
|
100
|
+
| **응답 종료** | Stop | 계측 flush(`events.jsonl`) |
|
|
101
|
+
|
|
102
|
+
> 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 기존 설치(0.2.1 이하 init)는 `gbc init --yes` 재실행으로 SessionStart hook이 추가된다.
|
|
103
|
+
|
|
92
104
|
### 시나리오 도출 루프 (수기 입력 불필요)
|
|
93
105
|
|
|
94
106
|
명세가 비어 **시나리오 미지정**으로 차단되면, 사용자가 파일을 직접 쓰지 않는다. 차단 메시지가 코딩 에이전트에게 다음을 지시한다:
|
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,12 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, } from "node:fs";
|
|
7
|
-
import { runPreToolUse, runStop } from "./hook.js";
|
|
7
|
+
import { runPreToolUse, runStop, runSessionStart } from "./hook.js";
|
|
8
8
|
import { loadPlanSpec, computeSpecHash, addSpecCase, readSpecCases, clearSpec } from "./spec.js";
|
|
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, normalizeHooks } from "./install.js";
|
|
12
|
+
import { buildPreCommand, normalizeHooks, ensureSessionStartHook } from "./install.js";
|
|
13
13
|
import { logEvent, parseEvents, computeMetrics } from "./metrics.js";
|
|
14
14
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
15
15
|
const PKG_ROOT = join(dirname(CLI_PATH), ".."); // dist/cli.js → 패키지 루트
|
|
@@ -60,7 +60,7 @@ function cmdInit(args) {
|
|
|
60
60
|
console.log(`🐢 gbc init — 다음을 수행합니다 (프로젝트 로컬만, 전역 ~/.claude 미변경):
|
|
61
61
|
|
|
62
62
|
대상 프로젝트: ${cwd}
|
|
63
|
-
1) ${settingsPath} 에 PreToolUse(Edit|Write) + Stop hook 추가 (머지·멱등)
|
|
63
|
+
1) ${settingsPath} 에 PreToolUse(Edit|Write) + Stop + SessionStart hook 추가 (머지·멱등)
|
|
64
64
|
- 기존 settings.json 있으면 백업: settings.json.bak-<시각>
|
|
65
65
|
2) ${join(skillDestDir, "SKILL.md")} 에 /gate 스킬 설치
|
|
66
66
|
3) hook 명령: ${buildPreCommand(CLI_PATH)}
|
|
@@ -113,6 +113,13 @@ ${hasApiKey()
|
|
|
113
113
|
else {
|
|
114
114
|
console.log(` = Stop hook 이미 존재 (skip)`);
|
|
115
115
|
}
|
|
116
|
+
// SessionStart (멱등) — 세션 진입(startup|resume) 시 미해결 defer 알림
|
|
117
|
+
if (ensureSessionStartHook(settings, CLI_PATH)) {
|
|
118
|
+
console.log(` + SessionStart hook 추가`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.log(` = SessionStart hook 이미 존재 (skip)`);
|
|
122
|
+
}
|
|
116
123
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
117
124
|
// /gate 스킬 설치
|
|
118
125
|
if (existsSync(skillSrc)) {
|
|
@@ -124,7 +131,7 @@ ${hasApiKey()
|
|
|
124
131
|
✅ 설치 완료. 트랜스포트: ${transport}${transport === "cli"
|
|
125
132
|
? " (ANTHROPIC_API_KEY 설정 시 직접 API로 ~1–3s, 미설정 시 claude -p 폴백 ~13–20s)"
|
|
126
133
|
: ""}
|
|
127
|
-
계획 명세는
|
|
134
|
+
계획 명세는 .gbc/spec.md 에 작성하세요(없으면 시나리오 미지정으로 차단 → 도출·검증 루프 발동: 에이전트가 요청에서 시나리오를 도출해 사용자 검증 후 'gbc spec add'로 등록).`);
|
|
128
135
|
}
|
|
129
136
|
// ---------- gbc status ----------
|
|
130
137
|
function cmdStatus() {
|
|
@@ -265,6 +272,7 @@ function usage() {
|
|
|
265
272
|
gbc metrics [--json] 계측 리포트(M1~M3, B-모드 관측 프록시)
|
|
266
273
|
gbc hook pre-tool-use (내부) PreToolUse hook
|
|
267
274
|
gbc hook stop (내부) Stop hook
|
|
275
|
+
gbc hook session-start (내부) SessionStart hook (미해결 defer 알림)
|
|
268
276
|
`);
|
|
269
277
|
}
|
|
270
278
|
async function main() {
|
|
@@ -275,7 +283,9 @@ async function main() {
|
|
|
275
283
|
return runPreToolUse();
|
|
276
284
|
if (rest[0] === "stop")
|
|
277
285
|
return runStop();
|
|
278
|
-
|
|
286
|
+
if (rest[0] === "session-start")
|
|
287
|
+
return runSessionStart();
|
|
288
|
+
console.error("사용: gbc hook <pre-tool-use|stop|session-start>");
|
|
279
289
|
process.exit(1);
|
|
280
290
|
break;
|
|
281
291
|
case "init":
|
package/dist/hook.js
CHANGED
|
@@ -29,10 +29,12 @@ export function buildBlockReason(verdict, specEmpty, source) {
|
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* pass verdict를 작업단위 캐시(markGated)에 넣어도 되는가.
|
|
32
|
-
* fail-open(판정 실패 안전통과)은 제외 — 일시 장애가 작업단위 내내 게이트를 무력화하는 것을 막는다.
|
|
32
|
+
* - fail-open(판정 실패 안전통과)은 제외 — 일시 장애가 작업단위 내내 게이트를 무력화하는 것을 막는다.
|
|
33
|
+
* - 빈 명세(specEmpty)도 제외 — 빈-spec hash는 상수라 한번 캐시되면 영원히 무효화 안 됨
|
|
34
|
+
* (= 게이트 교차세션 영구 우회, 2026-06-22 진단·수정). 빈 명세는 항상 재판정해야 한다.
|
|
33
35
|
*/
|
|
34
|
-
export function shouldCacheVerdict(verdict) {
|
|
35
|
-
return verdict.verdict === "pass" && !verdict.failOpen;
|
|
36
|
+
export function shouldCacheVerdict(verdict, specEmpty) {
|
|
37
|
+
return verdict.verdict === "pass" && !verdict.failOpen && !specEmpty;
|
|
36
38
|
}
|
|
37
39
|
function readStdin() {
|
|
38
40
|
return new Promise((resolve) => {
|
|
@@ -99,12 +101,16 @@ export async function runPreToolUse() {
|
|
|
99
101
|
}
|
|
100
102
|
const { text: specText, source } = loadPlanSpec(cwd);
|
|
101
103
|
const specHash = computeSpecHash(specText);
|
|
104
|
+
const specEmpty = specText.trim() === "";
|
|
102
105
|
// 계측용 해시: 빈 spec은 ""(센티넬)로 기록 → M1 churn 교차세션 합산 방지.
|
|
103
|
-
|
|
104
|
-
const logHash = specText.trim() === "" ? "" : specHash;
|
|
106
|
+
const logHash = specEmpty ? "" : specHash;
|
|
105
107
|
// 작업단위 1회: 이미 게이트 통과한 단위면 즉시 통과 (judge 미호출, 핫패스)
|
|
106
108
|
// 계측: cached-skip도 기록해야 M3(작업단위당 edit 반복)이 진짜 횟수를 잡는다.
|
|
107
|
-
|
|
109
|
+
// ⚠️ 빈 명세는 캐시를 절대 조회하지 않는다(read-side 가드) — 빈-spec hash는 상수라
|
|
110
|
+
// 한번 캐시된 pass가 영원히 무효화되지 않아 게이트가 교차세션으로 영구 무력화되던
|
|
111
|
+
// 결함(2026-06-22 진단)을 근본 차단. 빈 명세는 항상 재판정: judge [1단계] 사소한
|
|
112
|
+
// 편집 pass, [2단계]a 동작 편집 block. (기존에 오염된 state.json도 자동으로 무시됨)
|
|
113
|
+
if (!specEmpty && isGated(cwd, specHash)) {
|
|
108
114
|
logEvent(cwd, {
|
|
109
115
|
at: nowIso(),
|
|
110
116
|
session,
|
|
@@ -121,39 +127,42 @@ export async function runPreToolUse() {
|
|
|
121
127
|
const defers = activeDeferItems(cwd);
|
|
122
128
|
const verdict = await judge(specText, editText, defers);
|
|
123
129
|
if (verdict.verdict === "pass") {
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
// fail-open(판정 실패) 먼저 분기 — 빈-spec 정상 pass가 fail-open으로 오분류되지 않게.
|
|
131
|
+
if (verdict.failOpen) {
|
|
132
|
+
// 판정 실패로 안전 통과. 캐시하지 않아(작업단위 무력화 방지) 다음 편집에서 재판정.
|
|
133
|
+
// 계측 + systemMessage로 사용자에게 "게이트가 검사 못 했음"을 알린다(조용한 무력화 방지).
|
|
134
|
+
logFailOpen(cwd, toolName, verdict.reason);
|
|
126
135
|
logEvent(cwd, {
|
|
127
136
|
at: nowIso(),
|
|
128
137
|
session,
|
|
129
138
|
specHash: logHash,
|
|
130
139
|
kind: "gate",
|
|
131
140
|
tool: toolName,
|
|
132
|
-
decision: "
|
|
133
|
-
|
|
141
|
+
decision: "failopen",
|
|
142
|
+
});
|
|
143
|
+
emit({
|
|
144
|
+
systemMessage: `🐢 거북이 게이트 — 판정 실패로 안전 통과(fail-open). 이 편집은 게이트 검사를 받지 못했습니다: ${verdict.reason}`,
|
|
145
|
+
hookSpecificOutput: {
|
|
146
|
+
hookEventName: "PreToolUse",
|
|
147
|
+
permissionDecision: "allow",
|
|
148
|
+
permissionDecisionReason: verdict.reason,
|
|
149
|
+
},
|
|
134
150
|
});
|
|
135
|
-
process.exit(0);
|
|
151
|
+
process.exit(0);
|
|
136
152
|
}
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
153
|
+
// 정상 pass. 단 빈 명세 pass는 절대 캐시하지 않는다(상수 hash 영구 우회 방지).
|
|
154
|
+
if (shouldCacheVerdict(verdict, specEmpty))
|
|
155
|
+
markGated(cwd, specHash, verdict.reason);
|
|
140
156
|
logEvent(cwd, {
|
|
141
157
|
at: nowIso(),
|
|
142
158
|
session,
|
|
143
159
|
specHash: logHash,
|
|
144
160
|
kind: "gate",
|
|
145
161
|
tool: toolName,
|
|
146
|
-
decision: "
|
|
162
|
+
decision: "pass",
|
|
163
|
+
deferCount: defers.length,
|
|
147
164
|
});
|
|
148
|
-
|
|
149
|
-
systemMessage: `🐢 거북이 게이트 — 판정 실패로 안전 통과(fail-open). 이 편집은 게이트 검사를 받지 못했습니다: ${verdict.reason}`,
|
|
150
|
-
hookSpecificOutput: {
|
|
151
|
-
hookEventName: "PreToolUse",
|
|
152
|
-
permissionDecision: "allow",
|
|
153
|
-
permissionDecisionReason: verdict.reason,
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
process.exit(0);
|
|
165
|
+
process.exit(0); // 정상 통과 (자동승인 X — 무출력)
|
|
157
166
|
}
|
|
158
167
|
// block: 사람 pause (ask 기본) — 사유가 사용자에게 표시됨
|
|
159
168
|
// 시나리오 미지정(명세 빈약)과 침묵 누락을 다르게 안내한다.
|
|
@@ -208,3 +217,35 @@ export async function runStop() {
|
|
|
208
217
|
});
|
|
209
218
|
process.exit(0);
|
|
210
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* 세션 진입(startup|resume) 시 미해결 defer 잔여를 표면화하는 알림 문자열. 없으면 "".
|
|
222
|
+
* gbc 자기 소유 데이터(.gbc/defers.json)만 사용 — scratch/메모리 미접근(다른 하네스와 혼재·환각 방지).
|
|
223
|
+
*/
|
|
224
|
+
export function buildSessionStartHint(unresolved) {
|
|
225
|
+
if (unresolved.length === 0)
|
|
226
|
+
return "";
|
|
227
|
+
const items = unresolved.map((d, i) => `${i + 1}. ${d.item}`).join("\n");
|
|
228
|
+
return (`🐢 거북이 게이트 — 미해결 defer ${unresolved.length}건 (이전 작업 잔여):\n${items}\n` +
|
|
229
|
+
`필요하면 사용자에게 이어서 처리할지 확인하세요. 해결은 'gbc defer resolve <번호>'.`);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* SessionStart: 세션 진입 시 미해결 defer를 stdout(plain text)으로 표면화 → Claude 컨텍스트 주입.
|
|
233
|
+
* 잔여 없으면 무출력. GBC_NO_SESSION_HINT=1로 opt-out. 결정론적(LLM·코드비교 없음).
|
|
234
|
+
*/
|
|
235
|
+
export async function runSessionStart() {
|
|
236
|
+
if (process.env.GBC_NO_SESSION_HINT === "1")
|
|
237
|
+
process.exit(0);
|
|
238
|
+
let input = {};
|
|
239
|
+
try {
|
|
240
|
+
const raw = await readStdin();
|
|
241
|
+
input = raw ? JSON.parse(raw) : {};
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
process.exit(0);
|
|
245
|
+
}
|
|
246
|
+
const cwd = input.cwd || process.cwd();
|
|
247
|
+
const hint = buildSessionStartHint(unresolvedDefers(cwd));
|
|
248
|
+
if (hint)
|
|
249
|
+
process.stdout.write(hint);
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -31,3 +31,26 @@ export function normalizeHooks(settings, cliPath) {
|
|
|
31
31
|
}
|
|
32
32
|
return changed;
|
|
33
33
|
}
|
|
34
|
+
/** SessionStart hook 명령 — 셸 무관 순수 명령(buildPreCommand와 동일 규약). */
|
|
35
|
+
export function buildSessionStartCommand(cliPath) {
|
|
36
|
+
return `node "${cliPath}" hook session-start`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* SessionStart hook을 멱등 등록한다. matcher "startup|resume"로 신규 진입·재개에만 발화
|
|
40
|
+
* (compact마다 반복 노이즈 방지). 이미 'hook session-start' 명령이 있으면 추가하지 않는다.
|
|
41
|
+
* settings를 제자리 수정하고, 새로 추가했으면 true(이미 있으면 false)를 반환한다.
|
|
42
|
+
*/
|
|
43
|
+
export function ensureSessionStartHook(settings, cliPath) {
|
|
44
|
+
for (const entry of settings.hooks?.SessionStart ?? []) {
|
|
45
|
+
for (const h of entry.hooks ?? []) {
|
|
46
|
+
if (h.command.includes("hook session-start"))
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const hooks = (settings.hooks ??= {});
|
|
51
|
+
(hooks.SessionStart ??= []).push({
|
|
52
|
+
matcher: "startup|resume",
|
|
53
|
+
hooks: [{ type: "command", command: buildSessionStartCommand(cliPath) }],
|
|
54
|
+
});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
package/dist/spec.js
CHANGED
|
@@ -5,7 +5,11 @@ import { gbcDir } from "./store.js";
|
|
|
5
5
|
const MAX_SPEC = 12000; // 명세 텍스트 절단 (프롬프트 비대화 방지)
|
|
6
6
|
/**
|
|
7
7
|
* 계획 명세를 디스크에서 로드한다. (advisor④: durable 소스만 — 라이브 SubTask는 영속 X)
|
|
8
|
-
* 우선순위: GBC_SPEC_FILE > .gbc/spec.md >
|
|
8
|
+
* 우선순위: GBC_SPEC_FILE > .gbc/spec.md > "" (빈 명세 = 시나리오 미지정 → 통증#2 차단)
|
|
9
|
+
*
|
|
10
|
+
* .gbc/spec.md가 단일 정본(canonical). 다른 파일(예: 하네스의 scratch.md)을 명세로 쓰려면
|
|
11
|
+
* GBC_SPEC_FILE로 명시 지정한다 — gbc가 소유 안 한 파일을 자동 폴백하지 않는다(0.2.2:
|
|
12
|
+
* scratch.md 자동 폴백 제거. 진행추적 파일을 시나리오 명세로 오인하던 거짓음성 차단).
|
|
9
13
|
*
|
|
10
14
|
* 느슨 매칭은 게이트 LLM이 담당한다(체크리스트 라인/SubTask 항목). 로더는 텍스트만 제공.
|
|
11
15
|
*/
|
|
@@ -14,7 +18,6 @@ export function loadPlanSpec(cwd) {
|
|
|
14
18
|
if (process.env.GBC_SPEC_FILE)
|
|
15
19
|
candidates.push(process.env.GBC_SPEC_FILE);
|
|
16
20
|
candidates.push(join(cwd, ".gbc", "spec.md"));
|
|
17
|
-
candidates.push(join(cwd, "scratch.md"));
|
|
18
21
|
for (const path of candidates) {
|
|
19
22
|
if (existsSync(path)) {
|
|
20
23
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "geobuke-code",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "거북이코드 — 구현 직전 강제 게이트. Claude Code PreToolUse hook으로 코드 변경 전 계획 케이스 누락·시나리오 미지정을 차단한다.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch",
|
|
18
19
|
"test": "node --test 'test/**/*.test.mjs'",
|
|
19
20
|
"eval": "node dist/eval/regression.js",
|
|
20
21
|
"prepare": "tsc",
|