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 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
- 게이트는 계획 명세를 다음 우선순위로 읽는다(durable 소스):
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
- 계획 명세는 scratch.md 또는 .gbc/spec.md 에 작성하세요(없으면 시나리오 미지정으로 차단).`);
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
- console.error("사용: gbc hook <pre-tool-use|stop>");
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
- // (게이트 캐시용 specHash는 그대로 markGated/isGated 동작 불변)
104
- const logHash = specText.trim() === "" ? "" : specHash;
106
+ const logHash = specEmpty ? "" : specHash;
105
107
  // 작업단위 1회: 이미 게이트 통과한 단위면 즉시 통과 (judge 미호출, 핫패스)
106
108
  // 계측: cached-skip도 기록해야 M3(작업단위당 edit 반복)이 진짜 횟수를 잡는다.
107
- if (isGated(cwd, specHash)) {
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
- if (shouldCacheVerdict(verdict)) {
125
- markGated(cwd, specHash, verdict.reason);
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: "pass",
133
- deferCount: defers.length,
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); // 정상 통과 (자동승인 X — 무출력)
151
+ process.exit(0);
136
152
  }
137
- // fail-open: 판정 실패로 안전 통과. 캐시하지 않아(작업단위 무력화 방지) 다음 편집에서 재판정.
138
- // 계측 + systemMessage로 사용자에게 "게이트가 검사 못 했음"을 알린다(조용한 무력화 방지).
139
- logFailOpen(cwd, toolName, verdict.reason);
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: "failopen",
162
+ decision: "pass",
163
+ deferCount: defers.length,
147
164
  });
148
- emit({
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 > scratch.md > "" (빈 명세 = 시나리오 미지정 → 통증#2 차단)
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.1",
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",