geobuke-code 0.2.2 → 0.2.3

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
@@ -57,9 +57,9 @@ gbc init
57
57
 
58
58
  ## 빠른 게이트 활성화 (API 키 — 선택)
59
59
 
60
- 키가 없으면 `claude -p` 폴백(~13–20s)으로 무설정 동작한다. **haiku 직접 API(~1–3s)**를 쓰려면 **키 파일만 만들면 된다** — gbc가 실행 시 직접 읽으므로 settings.json 수정이나 셸 주입은 불필요하다(0.2.1+).
60
+ 키가 없으면 `claude -p` 폴백(~13–20s)으로 무설정 동작한다. **haiku 직접 API(~1–3s)**를 쓰려면 **키 파일만 만들면 된다** — gbc가 실행 시 직접 읽으므로 settings.json 수정이나 셸 주입은 불필요하다.
61
61
 
62
- > ⚠️ **native Windows에선 API 키가 사실상 필수다.** `claude -p` 폴백은 `claude.cmd`(배치 shim)를없이 실행하지 못해(ENOENT) fail-open으로 빠질 있다. Windows에선 위 키 파일을 만들어 API 경로로 쓰는 것을 권장한다(WSL/Mac/Linux는 폴백 정상).
62
+ > ⚠️ **native Windows에선 API 키를 권장한다.** 없는 `claude -p` 폴백도 지원하지만(win32에선 `claude.cmd` 실행을 위해 경유, 프롬프트는 인젝션 회피로 stdin 전달, 무응답 방지 kill-timeout), 폴백은 느리므로(~13–20s) 위 키 파일로 API 경로(~1–3s)를 쓰는 편이 빠르고 확실하다.
63
63
 
64
64
  키 해석 순서: `ANTHROPIC_API_KEY` 환경변수 > `~/.gbc/api-key` 파일.
65
65
 
@@ -76,7 +76,7 @@ Set-Content -Path "$HOME\.gbc\api-key" -Value "sk-ant-..." -NoNewline
76
76
 
77
77
  > ⚠️ **`export ANTHROPIC_API_KEY=…` 전역 설정(또는 settings.json top-level `env`) 금지.** Claude Code 본체가 그 키로 **과금 전환**된다(구독 대신 키 과금). 키 파일 방식은 gbc 판정 호출에만 키가 쓰이므로 이 함정을 구조적으로 피한다.
78
78
 
79
- `gbc status`는 키 파일/환경변수를 반영해 `트랜스포트: api`로 표시한다(0.2.1+).
79
+ `gbc status`는 키 파일/환경변수를 반영해 `트랜스포트: api`로 표시한다.
80
80
 
81
81
  ## 동작 원리
82
82
 
@@ -98,8 +98,11 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
98
98
  | **코드 변경 직전** | PreToolUse (`Edit\|Write\|MultiEdit`) | 명세 ↔ 변경 ↔ defer 대조 → 통과(침묵)/차단(시나리오 도출 지시)/fail-open |
99
99
  | **작업단위당 1회** | (PreToolUse 캐시) | 같은 명세 해시 내에선 첫 편집만 판정, 이후 통과 → 매 편집 지연 회피 |
100
100
  | **응답 종료** | Stop | 계측 flush(`events.jsonl`) |
101
+ | **업데이트 필요 시** | (PreToolUse·SessionStart·`gbc status`) | hook 구버전(②) 또는 신버전 출시(①)면 갱신 안내. PreToolUse는 세션당 1회(`systemMessage` 비차단), SessionStart는 진입 시 표시. 게이트 통과/차단 동작은 불변 |
101
102
 
102
- > 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 기존 설치(0.2.1 이하 init)는 `gbc init --yes` 재실행으로 SessionStart hook이 추가된다.
103
+ > 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 업데이트 안내만 끄려면 `GBC_NO_UPDATE_NOTICE=1`.
104
+ > 프로젝트 hook이 구식이거나(SessionStart 누락·옛 명령) 새 버전이 나오면 gbc가 감지해 `gbc init --yes` 재실행 또는 `npm i -g geobuke-code@latest`를 안내한다. PreToolUse 경로로도 알리므로 "설치만 하고 init 안 한" 경우에도 도달한다.
105
+ > **업데이트 안내(①)는 네트워크를 게이트 핫패스에 들이지 않는다**: `~/.gbc/version-check.json` 캐시만 비교하고, 갱신은 SessionStart·`gbc status`에서만 짧은 타임아웃으로. 조회 실패는 조용히 무시(fail-silent)되어 게이트 결정에 영향이 없다.
103
106
 
104
107
  ### 시나리오 도출 루프 (수기 입력 불필요)
105
108
 
@@ -119,7 +122,7 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
119
122
  | 조건 | 트랜스포트 | 지연 |
120
123
  |---|---|---|
121
124
  | 키 있음 (`ANTHROPIC_API_KEY` env 또는 `~/.gbc/api-key` 파일) | Anthropic API 직접 (haiku, 최소 시스템프롬프트) | ~1–3s (목표) |
122
- | 키 없음 | `claude -p` 폴백 (CC 인증 재사용, 무설정 / ⚠️native Windows 미지원) | ~13–20s |
125
+ | 키 없음 | `claude -p` 폴백 (CC 인증 재사용, 무설정 · native Windows 포함) | ~13–20s |
123
126
 
124
127
  **작업단위 1회**: 게이트는 작업단위(계획 명세 해시)당 한 번만 발동한다. 명세가 바뀌거나 명세 밖 파일을 편집할 때만 재발동 → 매 편집 지연을 피한다.
125
128
 
package/dist/cli.js CHANGED
@@ -10,9 +10,20 @@ import { loadState, resetGate } from "./state.js";
10
10
  import { addDefer, loadDefers, resolveDefer } from "./defer.js";
11
11
  import { selectedTransport } from "./judge.js";
12
12
  import { buildPreCommand, normalizeHooks, ensureSessionStartHook } from "./install.js";
13
+ import { isCacheStale, readVersionCache, refreshVersionCache, buildVersionNotice, } from "./version.js";
13
14
  import { logEvent, parseEvents, computeMetrics } from "./metrics.js";
14
15
  const CLI_PATH = fileURLToPath(import.meta.url);
15
16
  const PKG_ROOT = join(dirname(CLI_PATH), ".."); // dist/cli.js → 패키지 루트
17
+ /** 설치된 패키지 버전(업데이트 안내 비교 기준). 읽기 실패 시 "". */
18
+ function readPkgVersion() {
19
+ try {
20
+ return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "";
21
+ }
22
+ catch {
23
+ return "";
24
+ }
25
+ }
26
+ const PKG_VERSION = readPkgVersion();
16
27
  /** ~/.gbc/api-key 존재 여부 — 있으면 hook에 키 주입(빠른 haiku 경로). */
17
28
  function hasApiKey() {
18
29
  return existsSync(join(homedir(), ".gbc", "api-key"));
@@ -49,7 +60,7 @@ function nowStamp() {
49
60
  }
50
61
  }
51
62
  // ---------- gbc init ----------
52
- function cmdInit(args) {
63
+ async function cmdInit(args) {
53
64
  const cwd = process.cwd();
54
65
  const yes = args.includes("--yes") || args.includes("-y");
55
66
  const claudeDir = join(cwd, ".claude");
@@ -132,9 +143,28 @@ ${hasApiKey()
132
143
  ? " (ANTHROPIC_API_KEY 설정 시 직접 API로 ~1–3s, 미설정 시 claude -p 폴백 ~13–20s)"
133
144
  : ""}
134
145
  계획 명세는 .gbc/spec.md 에 작성하세요(없으면 시나리오 미지정으로 차단 → 도출·검증 루프 발동: 에이전트가 요청에서 시나리오를 도출해 사용자 검증 후 'gbc spec add'로 등록).`);
146
+ // 설치 직후 버전 캐시 seed — 신버전 안내(①)가 "설치만 하고 init 안 한" 코호트에도
147
+ // 신뢰성 있게 동작하도록(SessionStart 없는 환경의 유일한 seed 지점일 수 있음). best-effort.
148
+ try {
149
+ if (process.env.GBC_NO_UPDATE_NOTICE !== "1" && isCacheStale(readVersionCache())) {
150
+ await refreshVersionCache();
151
+ }
152
+ }
153
+ catch {
154
+ /* 갱신 실패는 무시(fail-silent) */
155
+ }
135
156
  }
136
157
  // ---------- gbc status ----------
137
- function cmdStatus() {
158
+ async function cmdStatus() {
159
+ // 버전 캐시가 stale면 갱신(status는 대화형이라 짧은 대기 허용). 실패는 무시.
160
+ try {
161
+ if (process.env.GBC_NO_UPDATE_NOTICE !== "1" && isCacheStale(readVersionCache())) {
162
+ await refreshVersionCache();
163
+ }
164
+ }
165
+ catch {
166
+ /* 갱신 실패 무시 */
167
+ }
138
168
  const cwd = process.cwd();
139
169
  const { text, source } = loadPlanSpec(cwd);
140
170
  const hash = computeSpecHash(text);
@@ -142,6 +172,7 @@ function cmdStatus() {
142
172
  const defers = loadDefers(cwd);
143
173
  const unresolved = defers.filter((d) => !d.resolved);
144
174
  console.log(`🐢 거북이 게이트 상태 — ${cwd}
175
+ 버전: ${PKG_VERSION || "(불명)"}
145
176
  트랜스포트: ${selectedTransport()}
146
177
  명세 소스: ${source} ${text ? `(${text.length}자)` : "(비어있음 → 모든 코드변경 차단)"}
147
178
  명세 해시: ${hash}
@@ -150,6 +181,9 @@ function cmdStatus() {
150
181
  if (unresolved.length > 0) {
151
182
  console.log(unresolved.map((d, i) => ` ${i + 1}. ${d.item}`).join("\n"));
152
183
  }
184
+ const verNotice = buildVersionNotice(PKG_VERSION, readVersionCache());
185
+ if (verNotice)
186
+ console.log(` ${verNotice}`);
153
187
  }
154
188
  // ---------- gbc defer ----------
155
189
  function cmdDefer(args) {
@@ -280,11 +314,11 @@ async function main() {
280
314
  switch (cmd) {
281
315
  case "hook":
282
316
  if (rest[0] === "pre-tool-use")
283
- return runPreToolUse();
317
+ return runPreToolUse({ cliPath: CLI_PATH, version: PKG_VERSION });
284
318
  if (rest[0] === "stop")
285
319
  return runStop();
286
320
  if (rest[0] === "session-start")
287
- return runSessionStart();
321
+ return runSessionStart({ cliPath: CLI_PATH, version: PKG_VERSION });
288
322
  console.error("사용: gbc hook <pre-tool-use|stop|session-start>");
289
323
  process.exit(1);
290
324
  break;
package/dist/defer.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { gbcDir, readJson, writeJson } from "./store.js";
3
+ import { normalizeCase } from "./text.js";
3
4
  function deferPath(cwd) {
4
5
  return join(gbcDir(cwd), "defers.json");
5
6
  }
@@ -20,7 +21,7 @@ function save(cwd, defers) {
20
21
  /** 명시적으로 항목을 미룬다 (침묵 누락 차단의 유일한 정당 경로) */
21
22
  export function addDefer(cwd, item) {
22
23
  const defers = loadDefers(cwd);
23
- const entry = { item, at: nowIso(), resolved: false };
24
+ const entry = { item: normalizeCase(item), at: nowIso(), resolved: false };
24
25
  defers.push(entry);
25
26
  save(cwd, defers);
26
27
  return entry;
package/dist/hook.js CHANGED
@@ -5,6 +5,8 @@ import { isGatedTool, normalizeEdit } from "./normalize.js";
5
5
  import { loadPlanSpec, computeSpecHash } from "./spec.js";
6
6
  import { isGated, markGated } from "./state.js";
7
7
  import { activeDeferItems, unresolvedDefers, loadDefers } from "./defer.js";
8
+ import { readProjectSettings, buildUpdateNotice, wasNotified, markNotified } from "./notice.js";
9
+ import { isCacheStale, readVersionCache, refreshVersionCache } from "./version.js";
8
10
  import { appendFileSync } from "node:fs";
9
11
  import { join } from "node:path";
10
12
  import { gbcDir } from "./store.js";
@@ -76,8 +78,27 @@ function logFailOpen(cwd, toolName, reason) {
76
78
  /* 계측 실패는 무시 */
77
79
  }
78
80
  }
81
+ /**
82
+ * 업데이트 안내 문자열(세션당 1회). 안내는 게이트와 완전 독립 — 어떤 실패도 게이트 결정에
83
+ * 영향 주지 않게 전체를 try/catch로 감싼다(fail-silent). cliPath 없으면(직접 hook 호출 등) "".
84
+ */
85
+ function maybeUpdateNotice(cwd, session, ctx) {
86
+ try {
87
+ if (!ctx?.cliPath)
88
+ return "";
89
+ if (wasNotified(cwd, session))
90
+ return "";
91
+ const notice = buildUpdateNotice(readProjectSettings(cwd), ctx.cliPath, ctx.version ?? "");
92
+ if (notice)
93
+ markNotified(cwd, session);
94
+ return notice;
95
+ }
96
+ catch {
97
+ return "";
98
+ }
99
+ }
79
100
  /** PreToolUse: 코드 변경 직전 게이트 */
80
- export async function runPreToolUse() {
101
+ export async function runPreToolUse(ctx) {
81
102
  let input = {};
82
103
  try {
83
104
  const raw = await readStdin();
@@ -162,7 +183,12 @@ export async function runPreToolUse() {
162
183
  decision: "pass",
163
184
  deferCount: defers.length,
164
185
  });
165
- process.exit(0); // 정상 통과 (자동승인 X 무출력)
186
+ // 업데이트 안내(있으면) 비차단 노출 systemMessage 단독은 permissionDecision 없으니
187
+ // 도구를 자동승인하지 않고(통과 동작 보존) 메시지만 표시한다.
188
+ const passNotice = maybeUpdateNotice(cwd, session, ctx);
189
+ if (passNotice)
190
+ emit({ systemMessage: passNotice });
191
+ process.exit(0); // 정상 통과 (자동승인 X)
166
192
  }
167
193
  // block: 사람 pause (ask 기본) — 사유가 사용자에게 표시됨
168
194
  // 시나리오 미지정(명세 빈약)과 침묵 누락을 다르게 안내한다.
@@ -178,14 +204,19 @@ export async function runPreToolUse() {
178
204
  deferCount: defers.length,
179
205
  });
180
206
  const mode = process.env.GBC_BLOCK_MODE === "deny" ? "deny" : "ask";
181
- emit({
207
+ const blockOut = {
182
208
  hookSpecificOutput: {
183
209
  hookEventName: "PreToolUse",
184
210
  permissionDecision: mode,
185
211
  permissionDecisionReason: reason,
186
212
  additionalContext: reason,
187
213
  },
188
- });
214
+ };
215
+ // 업데이트 안내(있으면)를 같은 출력에 top-level systemMessage로 덧붙인다(차단 동작 불변).
216
+ const blockNotice = maybeUpdateNotice(cwd, session, ctx);
217
+ if (blockNotice)
218
+ blockOut.systemMessage = blockNotice;
219
+ emit(blockOut);
189
220
  process.exit(0);
190
221
  }
191
222
  /** Stop: 미해결 defer 리마인드 (stop_hook_active 가드로 1회만) */
@@ -232,9 +263,7 @@ export function buildSessionStartHint(unresolved) {
232
263
  * SessionStart: 세션 진입 시 미해결 defer를 stdout(plain text)으로 표면화 → Claude 컨텍스트 주입.
233
264
  * 잔여 없으면 무출력. GBC_NO_SESSION_HINT=1로 opt-out. 결정론적(LLM·코드비교 없음).
234
265
  */
235
- export async function runSessionStart() {
236
- if (process.env.GBC_NO_SESSION_HINT === "1")
237
- process.exit(0);
266
+ export async function runSessionStart(ctx) {
238
267
  let input = {};
239
268
  try {
240
269
  const raw = await readStdin();
@@ -244,8 +273,36 @@ export async function runSessionStart() {
244
273
  process.exit(0);
245
274
  }
246
275
  const cwd = input.cwd || process.cwd();
247
- const hint = buildSessionStartHint(unresolvedDefers(cwd));
248
- if (hint)
249
- process.stdout.write(hint);
276
+ const parts = [];
277
+ // 미해결 defer 알림(GBC_NO_SESSION_HINT로 opt-out — 기존 동작 보존).
278
+ if (process.env.GBC_NO_SESSION_HINT !== "1") {
279
+ const hint = buildSessionStartHint(unresolvedDefers(cwd));
280
+ if (hint)
281
+ parts.push(hint);
282
+ }
283
+ // 업데이트 안내(staleness + version) — SessionStart 보유 코호트(0.2.3+)용. 세션 식별자가 없어
284
+ // 항상 표시되므로 dedup 대신 GBC_NO_UPDATE_NOTICE opt-out에 맡긴다(buildUpdateNotice 내부).
285
+ try {
286
+ if (ctx?.cliPath) {
287
+ const notice = buildUpdateNotice(readProjectSettings(cwd), ctx.cliPath, ctx.version ?? "");
288
+ if (notice)
289
+ parts.push(notice);
290
+ }
291
+ }
292
+ catch {
293
+ /* 안내 실패는 무시(fail-silent) */
294
+ }
295
+ if (parts.length > 0)
296
+ process.stdout.write(parts.join("\n"));
297
+ // 버전 캐시 갱신은 '표시 후'에만(이번 출력은 캐시값 기준, 갱신은 다음 세션용). SessionStart는
298
+ // 게이트를 막지 않으므로 짧은 타임아웃 네트워크가 안전(advisor 승인). 실패는 조용히 무시.
299
+ try {
300
+ if (ctx?.cliPath && process.env.GBC_NO_UPDATE_NOTICE !== "1" && isCacheStale(readVersionCache())) {
301
+ await refreshVersionCache();
302
+ }
303
+ }
304
+ catch {
305
+ /* 갱신 실패는 무시(fail-silent) */
306
+ }
250
307
  process.exit(0);
251
308
  }
package/dist/install.js CHANGED
@@ -35,6 +35,30 @@ export function normalizeHooks(settings, cliPath) {
35
35
  export function buildSessionStartCommand(cliPath) {
36
36
  return `node "${cliPath}" hook session-start`;
37
37
  }
38
+ /**
39
+ * (read-only) PreToolUse hook 명령이 현재 표준(pure)과 다른 구버전인지. normalizeHooks의
40
+ * 감지부만 떼어낸 비파괴 술어 — ②init-staleness 안내가 settings를 수정하지 않고 판단하게 한다.
41
+ */
42
+ export function hasStalePreToolUse(settings, cliPath) {
43
+ const target = buildPreCommand(cliPath);
44
+ for (const entry of settings.hooks?.PreToolUse ?? []) {
45
+ for (const h of entry.hooks ?? []) {
46
+ if (h.command.includes("hook pre-tool-use") && h.command !== target)
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+ /** (read-only) SessionStart hook(session-start 명령)이 등록돼 있는지. 0.2.1 이하 init엔 없음. */
53
+ export function hasSessionStartHook(settings) {
54
+ for (const entry of settings.hooks?.SessionStart ?? []) {
55
+ for (const h of entry.hooks ?? []) {
56
+ if (h.command.includes("hook session-start"))
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
38
62
  /**
39
63
  * SessionStart hook을 멱등 등록한다. matcher "startup|resume"로 신규 진입·재개에만 발화
40
64
  * (compact마다 반복 노이즈 방지). 이미 'hook session-start' 명령이 있으면 추가하지 않는다.
package/dist/judge.js CHANGED
@@ -1,10 +1,18 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  const execFileAsync = promisify(execFile);
7
7
  const MODEL = process.env.GBC_MODEL ?? "claude-haiku-4-5";
8
+ const CLI_TIMEOUT_MS = 30000; // claude -p 폴백 상한(행 방지). 초과 시 kill → fail-open.
9
+ /**
10
+ * 모델 토큰을 셸 안전 문자로 제한한다(win32 shell:true 경로의 argv 인젝션 차단).
11
+ * GBC_MODEL은 사용자 env지만, 셸 경로에선 메타문자가 명령으로 새지 않게 화이트리스트만 통과.
12
+ */
13
+ export function safeModel(model) {
14
+ return /^[\w.-]+$/.test(model) ? model : "claude-haiku-4-5";
15
+ }
8
16
  /**
9
17
  * API 키 해석 (크로스플랫폼, 셸 무관).
10
18
  * 1) ANTHROPIC_API_KEY 환경변수 우선, 2) 없으면 ~/.gbc/api-key 파일.
@@ -102,10 +110,63 @@ async function judgeViaApi(system, user) {
102
110
  }
103
111
  /** claude -p 폴백 (무설정 도그푸딩용, 느림). CC의 기존 인증 사용. */
104
112
  async function judgeViaCli(system, user) {
105
- const { stdout } = await execFileAsync("claude", ["-p", user, "--append-system-prompt", system, "--model", MODEL, "--output-format", "json"], { maxBuffer: 10 * 1024 * 1024 });
113
+ // native Windows는 claude.cmd라 별도 경로(아래) POSIX는 검증된 경로 그대로(8/8 회귀 보존).
114
+ if (process.platform === "win32")
115
+ return judgeViaCliWin(system, user);
116
+ const { stdout } = await execFileAsync("claude", ["-p", user, "--append-system-prompt", system, "--model", MODEL, "--output-format", "json"], { maxBuffer: 10 * 1024 * 1024, timeout: CLI_TIMEOUT_MS });
106
117
  const env = JSON.parse(stdout);
107
118
  return env.result ?? "";
108
119
  }
120
+ /**
121
+ * native Windows 폴백. claude는 claude.cmd(배치 shim)라 Node 18+가 shell 없이 spawn 못 한다
122
+ * (CVE-2024-27980 → ENOENT). shell:true로 실행하되, argv엔 동적 데이터를 절대 두지 않는다:
123
+ * system+user를 합쳐 stdin으로 전달 → argv는 고정 플래그뿐이라 셸 메타문자 인젝션 표면이 없다.
124
+ * (W3 stdin 결합은 WSL에서 claude -p 실측으로 판정 품질 동일 검증함 — POSIX/win32 양 경로의
125
+ * '판정 품질'만 프롬프트 전달 방식이 다르고, 이는 가시적 fail-open으로 바운드된다.)
126
+ * kill-timeout 필수 — 무응답 stdin이 PreToolUse를 무한 차단하면 ENOENT보다 나쁘다 → fail-open 강제.
127
+ */
128
+ function judgeViaCliWin(system, user) {
129
+ return new Promise((resolve, reject) => {
130
+ const prompt = `${system}\n\n${user}`;
131
+ const child = spawn("claude", ["-p", "--model", safeModel(MODEL), "--output-format", "json"], {
132
+ shell: true,
133
+ });
134
+ let out = "";
135
+ let err = "";
136
+ let done = false;
137
+ const finish = (fn) => {
138
+ if (done)
139
+ return;
140
+ done = true;
141
+ clearTimeout(timer);
142
+ fn();
143
+ };
144
+ const timer = setTimeout(() => {
145
+ child.kill();
146
+ finish(() => reject(new Error(`claude -p 타임아웃(${CLI_TIMEOUT_MS}ms)`)));
147
+ }, CLI_TIMEOUT_MS);
148
+ child.stdout?.on("data", (d) => (out += String(d)));
149
+ child.stderr?.on("data", (d) => (err += String(d)));
150
+ child.on("error", (e) => finish(() => reject(e)));
151
+ child.on("close", (code) => finish(() => {
152
+ if (code !== 0) {
153
+ reject(new Error(`claude exited ${code}: ${err.slice(0, 200)}`));
154
+ return;
155
+ }
156
+ try {
157
+ resolve(JSON.parse(out).result ?? "");
158
+ }
159
+ catch (e) {
160
+ reject(e);
161
+ }
162
+ }));
163
+ child.stdin?.on("error", () => {
164
+ /* EPIPE 등은 close/error 핸들러가 처리 */
165
+ });
166
+ child.stdin?.write(prompt);
167
+ child.stdin?.end();
168
+ });
169
+ }
109
170
  /**
110
171
  * 게이트 판정. ANTHROPIC_API_KEY 있으면 직접 API(빠름), 없으면 claude -p 폴백.
111
172
  * 실패 시 안전하게 pass(fail-open) — 게이트가 개발을 막아버리는 사고 방지.
package/dist/notice.js ADDED
@@ -0,0 +1,75 @@
1
+ // 업데이트 안내 — ②init-staleness(결정론적, 네트워크 없음) + ①version(캐시 비교, ST4에서 추가).
2
+ // PreToolUse cache-miss 경로 + SessionStart + gbc status에서 호출, 세션당 1회 dedup.
3
+ // 게이트 판정과 완전 독립 — 안내 실패가 게이트 결정에 절대 영향 주지 않는다(fail-silent).
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { gbcDir } from "./store.js";
7
+ import { hasStalePreToolUse, hasSessionStartHook } from "./install.js";
8
+ import { readVersionCache, buildVersionNotice } from "./version.js";
9
+ /** 프로젝트 로컬 .claude/settings.json 읽기(없거나 깨지면 빈 객체). read-only. */
10
+ export function readProjectSettings(cwd) {
11
+ try {
12
+ return JSON.parse(readFileSync(join(cwd, ".claude", "settings.json"), "utf8"));
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ /**
19
+ * ②init-staleness: settings.json 상태로 hook 구버전/누락을 감지해 'gbc init --yes' 재실행을
20
+ * 안내한다. 버전 숫자가 아니라 실제 hook 상태로 판단 → 정말 필요한 프로젝트만 알린다.
21
+ * - SessionStart 미등록: 0.2.1 이하 init 코호트(가장 흔한 staleness). PreToolUse 경로로만 도달 가능.
22
+ * - PreToolUse 명령 구식: 옛 bash 키주입 prefix 등.
23
+ * 둘 다 아니면 "".
24
+ */
25
+ export function buildInitStalenessNotice(settings, cliPath) {
26
+ const stale = hasStalePreToolUse(settings, cliPath);
27
+ const missingSession = !hasSessionStartHook(settings);
28
+ if (!stale && !missingSession)
29
+ return "";
30
+ const reasons = [];
31
+ if (missingSession)
32
+ reasons.push("SessionStart hook 미등록");
33
+ if (stale)
34
+ reasons.push("PreToolUse hook 명령 구식");
35
+ return (`🐢 거북이 게이트 — 이 프로젝트 hook이 최신이 아닙니다(${reasons.join(", ")}). ` +
36
+ `'gbc init --yes' 재실행을 권장합니다(머지·멱등·백업).`);
37
+ }
38
+ function notifiedPath(cwd) {
39
+ return join(gbcDir(cwd), "notified.json");
40
+ }
41
+ /** 이 세션에서 이미 안내했는지 — 같은 session_id면 true. dedup(세션당 1회). fail-silent. */
42
+ export function wasNotified(cwd, session) {
43
+ try {
44
+ const j = JSON.parse(readFileSync(notifiedPath(cwd), "utf8"));
45
+ return j.session === session;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /** 이 세션을 '안내함'으로 기록. 기록 실패는 무시(안내가 게이트를 방해하지 않게). */
52
+ export function markNotified(cwd, session) {
53
+ try {
54
+ writeFileSync(notifiedPath(cwd), JSON.stringify({ session, at: new Date().toISOString() }));
55
+ }
56
+ catch {
57
+ /* 안내 기록 실패는 무시(fail-silent) */
58
+ }
59
+ }
60
+ /**
61
+ * 업데이트 안내 조합 문자열(②init-staleness + ①신버전). 없으면 "".
62
+ * GBC_NO_UPDATE_NOTICE=1이면 항상 "". 네트워크 없음 — version은 캐시(~/.gbc/version-check.json)만 읽는다.
63
+ */
64
+ export function buildUpdateNotice(settings, cliPath, currentVersion, home) {
65
+ if (process.env.GBC_NO_UPDATE_NOTICE === "1")
66
+ return "";
67
+ const lines = [];
68
+ const stale = buildInitStalenessNotice(settings, cliPath);
69
+ if (stale)
70
+ lines.push(stale);
71
+ const version = buildVersionNotice(currentVersion, readVersionCache(home));
72
+ if (version)
73
+ lines.push(version);
74
+ return lines.join("\n");
75
+ }
package/dist/spec.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, writeFileSync, appendFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve, sep } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { gbcDir } from "./store.js";
5
+ import { normalizeCase } from "./text.js";
5
6
  const MAX_SPEC = 12000; // 명세 텍스트 절단 (프롬프트 비대화 방지)
6
7
  /**
7
8
  * 계획 명세를 디스크에서 로드한다. (advisor④: durable 소스만 — 라이브 SubTask는 영속 X)
@@ -15,8 +16,17 @@ const MAX_SPEC = 12000; // 명세 텍스트 절단 (프롬프트 비대화 방
15
16
  */
16
17
  export function loadPlanSpec(cwd) {
17
18
  const candidates = [];
18
- if (process.env.GBC_SPEC_FILE)
19
- candidates.push(process.env.GBC_SPEC_FILE);
19
+ if (process.env.GBC_SPEC_FILE) {
20
+ // W1: 상대경로는 프로젝트 cwd 기준으로 해석한다(hook 프로세스의 cwd가 아니라). 절대경로는 그대로.
21
+ const resolved = resolve(cwd, process.env.GBC_SPEC_FILE);
22
+ // 컨테인먼트는 '차단'이 아니라 '경고만' — GBC_SPEC_FILE은 0.2.2에서 의도한 escape-hatch라
23
+ // cwd 밖 공유 명세를 명시 지정하는 정당 용례가 있다. env는 신뢰 경계 밖이 아니므로
24
+ // 막기보다, 예기치 않게 프로젝트 밖을 가리킬 때 사용자에게 보이게만 한다(stderr).
25
+ if (!resolved.startsWith(cwd + sep)) {
26
+ console.error(`🐢 gbc: GBC_SPEC_FILE이 프로젝트 밖을 가리킵니다(${resolved}). 의도한 것인지 확인하세요.`);
27
+ }
28
+ candidates.push(resolved);
29
+ }
20
30
  candidates.push(join(cwd, ".gbc", "spec.md"));
21
31
  for (const path of candidates) {
22
32
  if (existsSync(path)) {
@@ -39,7 +49,6 @@ export function computeSpecHash(text) {
39
49
  // --- spec.md 쓰기 (gbc spec 서브커맨드 백엔드) ---
40
50
  // 도출→검증→등록 루프에서, 사용자 승인된 시나리오를 durable 명세로 기록한다.
41
51
  // 주 경로는 에이전트가 .gbc/spec.md를 직접 작성하는 것이고, 이 CLI는 한 줄 케이스 추가용 보조.
42
- const MAX_CASE = 500; // 한 케이스 길이 상한 (spec.md 비대화·무제한 기록 방지)
43
52
  function specPath(cwd) {
44
53
  return join(gbcDir(cwd), "spec.md");
45
54
  }
@@ -50,8 +59,7 @@ function specPath(cwd) {
50
59
  */
51
60
  export function addSpecCase(cwd, item) {
52
61
  const path = specPath(cwd);
53
- const normalized = item.trim().replace(/\s*\n+\s*/g, " ").slice(0, MAX_CASE);
54
- const line = `- [ ] ${normalized}\n`;
62
+ const line = `- [ ] ${normalizeCase(item)}\n`;
55
63
  if (existsSync(path)) {
56
64
  appendFileSync(path, line, "utf8");
57
65
  }
package/dist/text.js ADDED
@@ -0,0 +1,12 @@
1
+ // 케이스 텍스트 정규화 (spec.md 케이스 / defer 케이스 공용).
2
+ // spec add와 defer add가 같은 규칙을 쓰도록 단일 소스로 추출한다(W2: 비대칭 제거).
3
+ /** 한 케이스 길이 상한 — 무제한 기록·프롬프트 비대화 방지. spec/defer 공통. */
4
+ export const MAX_CASE = 500;
5
+ /**
6
+ * 케이스 한 줄을 정규화한다: 앞뒤 공백 제거 → 내부 줄바꿈을 단일 공백으로 접기 →
7
+ * 길이 상한 절단. readSpecCases의 단일라인 매칭·activeDeferItems 입력과 정합되게,
8
+ * 에이전트가 멀티라인/장문을 그대로 넘겨도 안전하다.
9
+ */
10
+ export function normalizeCase(item) {
11
+ return item.trim().replace(/\s*\n+\s*/g, " ").slice(0, MAX_CASE);
12
+ }
@@ -0,0 +1,96 @@
1
+ // ①신버전 안내 — npm 최신 버전을 캐시에 두고 SessionStart/PreToolUse/status에서 비교만 한다.
2
+ // hook 핫패스에 동기 네트워크를 들이지 않는다: 표시는 캐시만 읽고, 갱신은 안전한 지점
3
+ // (SessionStart·status)에서 짧은 타임아웃 fetch로만. 실패는 조용히 무시(게이트 무관, fail-silent).
4
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ const PKG = "geobuke-code";
8
+ const TTL_MS = 24 * 60 * 60 * 1000; // 24h
9
+ const FETCH_TIMEOUT_MS = 1500;
10
+ export function cachePath(home = homedir()) {
11
+ return join(home, ".gbc", "version-check.json");
12
+ }
13
+ /**
14
+ * semver 비교(숫자 major.minor.patch만). a<b면 -1, 같으면 0, a>b면 1.
15
+ * prerelease/빌드메타·비숫자는 비교 불가로 보고 0(안내 안 함 → 거짓 안내 방지).
16
+ */
17
+ export function compareVersions(a, b) {
18
+ const pa = a.split("-")[0].split(".");
19
+ const pb = b.split("-")[0].split(".");
20
+ for (let i = 0; i < 3; i++) {
21
+ const x = Number(pa[i] ?? 0);
22
+ const y = Number(pb[i] ?? 0);
23
+ if (Number.isNaN(x) || Number.isNaN(y))
24
+ return 0;
25
+ if (x < y)
26
+ return -1;
27
+ if (x > y)
28
+ return 1;
29
+ }
30
+ return 0;
31
+ }
32
+ export function readVersionCache(home) {
33
+ try {
34
+ const j = JSON.parse(readFileSync(cachePath(home), "utf8"));
35
+ if (typeof j.latest === "string" && typeof j.checkedAt === "number")
36
+ return j;
37
+ return null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function writeVersionCache(cache, home) {
44
+ try {
45
+ const path = cachePath(home);
46
+ // ~/.gbc가 없을 수 있다(api-key 없는 신규 설치 = ① 안내의 주 타깃) → 디렉토리 보장 후 기록.
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ writeFileSync(path, JSON.stringify(cache));
49
+ }
50
+ catch {
51
+ /* 캐시 기록 실패는 무시(fail-silent) */
52
+ }
53
+ }
54
+ /** 캐시가 없거나 TTL(24h) 초과면 stale. now는 주입 가능(테스트). */
55
+ export function isCacheStale(cache, now = Date.now()) {
56
+ if (!cache)
57
+ return true;
58
+ return now - cache.checkedAt > TTL_MS;
59
+ }
60
+ /**
61
+ * 신버전 안내 문자열(캐시만 비교, 네트워크 없음). cache.latest > current일 때만 안내, 아니면 "".
62
+ * 캐시 없음/비교불가/동일·하위면 "".
63
+ */
64
+ export function buildVersionNotice(current, cache) {
65
+ if (!cache || !cache.latest || !current)
66
+ return "";
67
+ if (compareVersions(current, cache.latest) >= 0)
68
+ return "";
69
+ return (`🐢 거북이코드 신버전 ${cache.latest} 사용 가능(현재 ${current}). ` +
70
+ `갱신: npm i -g geobuke-code@latest → 각 프로젝트서 gbc init --yes`);
71
+ }
72
+ /**
73
+ * npm 레지스트리에서 최신 버전을 받아 캐시에 쓴다(짧은 타임아웃, 비차단·fail-silent).
74
+ * spawn(npm) 대신 fetch — Windows .cmd 실행 문제를 피한다. 실패·타임아웃은 조용히 무시.
75
+ */
76
+ export async function refreshVersionCache(home, now = Date.now()) {
77
+ try {
78
+ const ctrl = new AbortController();
79
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
80
+ try {
81
+ const resp = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: ctrl.signal });
82
+ if (!resp.ok)
83
+ return;
84
+ const j = (await resp.json());
85
+ if (j && typeof j.version === "string") {
86
+ writeVersionCache({ latest: j.version, checkedAt: now }, home);
87
+ }
88
+ }
89
+ finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+ catch {
94
+ /* 네트워크 실패·타임아웃·파싱 실패 모두 무시(fail-silent) */
95
+ }
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geobuke-code",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "거북이코드 — 구현 직전 강제 게이트. Claude Code PreToolUse hook으로 코드 변경 전 계획 케이스 누락·시나리오 미지정을 차단한다.",
5
5
  "type": "module",
6
6
  "bin": {