geobuke-code 0.4.1 → 0.4.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
@@ -112,7 +112,7 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
112
112
 
113
113
  > 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 매 대화 종료(Stop) defer 리마인드만 끄려면 `gbc defer mute`(영속, 해제 `unmute` · 스킬 `/gbc-mute`) — 진입 알림은 남는다. 업데이트 안내만 끄려면 `GBC_NO_UPDATE_NOTICE=1`.
114
114
  > 프로젝트 hook이 구식이거나(SessionStart 누락·옛 명령) 새 버전이 나오면 gbc가 감지해 **`gbc update`**(전역 최신 + 현재 프로젝트 재init 한방) 또는 수동 `npm i -g geobuke-code@latest → gbc init --yes`를 안내한다. 단 안내는 **이미 hook이 등록된 프로젝트**(=한 번이라도 `gbc init`을 한 코호트)에만 도달한다 — 전혀 init하지 않은 프로젝트엔 실행할 hook이 없어 구조적으로 알릴 수 없다(gbc는 전역 hook을 깔지 않는다).
115
- > **업데이트 안내(①)는 네트워크를 게이트 핫패스에 들이지 않는다**: `~/.gbc/version-check.json` 캐시만 비교하고, 갱신 fetch는 안전한 비-핫패스에서만 짧은 타임아웃(1.5s)으로. ⓐSessionStart는 캐시가 stale이면 **표시 전에 갱신**해 신버전이 그 세션에 바로 뜬다(1세션 지연 없음). ⓑ**PreToolUse는 judge를 도는 편집(cache-miss)에서 캐시가 stale이면 refresh를 judge와 *병렬*로 건다**(0.3.0) — judge가 ≥1.5s라 지연 0이고, 사용자가 `gbc status`를 직접 치지 않아도 캐시가 최신이 된다. **cached-skip 핫패스에는 네트워크를 절대 넣지 않는다.** 조회 실패는 조용히 무시(fail-silent)되어 게이트 결정에 영향이 없다. 캐시 TTL 24h.
115
+ > **업데이트 안내(①)는 네트워크를 게이트 핫패스에 들이지 않는다**: `~/.gbc/version-check.json` 캐시만 비교하고, 갱신 fetch는 안전한 비-핫패스에서만 짧은 타임아웃(1.5s)으로. ⓐSessionStart는 캐시가 stale이면 **표시 전에 갱신**해 신버전이 그 세션에 바로 뜬다(1세션 지연 없음). ⓑ**PreToolUse는 judge를 도는 편집(cache-miss)에서 캐시가 stale이면 refresh를 judge와 *병렬*로 건다**(0.3.0) — judge가 ≥1.5s라 지연 0이고, 사용자가 `gbc status`를 직접 치지 않아도 캐시가 최신이 된다. **cached-skip 핫패스에는 네트워크를 절대 넣지 않는다.** 조회 실패는 조용히 무시(fail-silent)되어 게이트 결정에 영향이 없다. 캐시 TTL 12h.
116
116
 
117
117
  ### 시나리오 도출 루프 (수기 입력 불필요)
118
118
 
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, lstatSync, } from "node:fs";
8
8
  import { runPreToolUse, runStop, runSessionStart } from "./hook.js";
9
- import { loadPlanSpec, computeSpecHash, addSpecCase, readSpecCases, clearSpec } from "./spec.js";
9
+ import { loadPlanSpec, computeSpecHash, addSpecCase, readSpecCases, clearSpec, archiveSpec, } from "./spec.js";
10
10
  import { loadState, resetGate } from "./state.js";
11
11
  import { addDefer, loadDefers, resolveDefer, startDefer, reopenDefer } from "./defer.js";
12
12
  import { loadRepos, addRepo, removeRepo } from "./repos.js";
@@ -231,9 +231,13 @@ function cmdDefer(args) {
231
231
  console.error('사용: gbc defer add "<케이스 설명>"');
232
232
  process.exit(1);
233
233
  }
234
- addDefer(cwd, item);
235
- logCli(cwd, "defer-add", curHash(cwd));
236
- console.log(`🐢 미룸 등록: ${item}`);
234
+ if (addDefer(cwd, item).added) {
235
+ logCli(cwd, "defer-add", curHash(cwd));
236
+ console.log(`🐢 미룸 등록: ${item}`);
237
+ }
238
+ else {
239
+ console.log(`🐢 이미 미해결로 미룬 항목 — 중복 등록 skip: ${item}`);
240
+ }
237
241
  }
238
242
  else if (sub === "mute" || sub === "unmute") {
239
243
  const muted = sub === "mute";
@@ -295,9 +299,13 @@ function cmdSpec(args) {
295
299
  process.exit(1);
296
300
  }
297
301
  const beforeHash = curHash(cwd); // 변이 전 해시 = 수정 대상 작업단위와 상관
298
- addSpecCase(cwd, item);
299
- logCli(cwd, "spec-add", beforeHash);
300
- console.log(`🐢 명세 등록: ${item}`);
302
+ if (addSpecCase(cwd, item)) {
303
+ logCli(cwd, "spec-add", beforeHash);
304
+ console.log(`🐢 명세 등록: ${item}`);
305
+ }
306
+ else {
307
+ console.log(`🐢 이미 등록된 케이스 — 중복 등록 skip: ${item}`);
308
+ }
301
309
  }
302
310
  else if (sub === "show") {
303
311
  const cases = readSpecCases(cwd);
@@ -318,6 +326,27 @@ function cmdSpec(args) {
318
326
  process.exit(1);
319
327
  }
320
328
  }
329
+ // ---------- gbc done ----------
330
+ /**
331
+ * 작업단위 명시 종료(ST3). spec.md 본문을 아카이브→비우고 게이트를 리셋한다.
332
+ * drift 근본수정: "완료" 이벤트 부재로 옛 케이스가 누적·부활하던 것을, 명시적 완료 신호로 닫는다.
333
+ * gate reset 로직(resetGate)은 변경하지 않고 그대로 호출만 한다(재게이트 의미 보존).
334
+ * defer는 건드리지 않는다 — 미해결 defer는 작업단위를 넘어 이월되는 별도 수명주기다.
335
+ */
336
+ function cmdDone() {
337
+ const cwd = process.cwd();
338
+ const beforeHash = curHash(cwd);
339
+ const archived = archiveSpec(cwd);
340
+ logCli(cwd, "done", beforeHash);
341
+ resetGate(cwd);
342
+ if (archived) {
343
+ console.log(`🐢 작업단위 종료 — 명세 아카이브: ${archived}`);
344
+ }
345
+ else {
346
+ console.log("🐢 작업단위 종료 — 비울 명세가 없습니다(이미 비어 있음).");
347
+ }
348
+ console.log(" 게이트 리셋 완료. 다음 작업단위는 새 명세로 시작하세요('gbc spec add').");
349
+ }
321
350
  // ---------- gbc gate ----------
322
351
  async function cmdGate(args) {
323
352
  const cwd = process.cwd();
@@ -405,7 +434,7 @@ async function cmdGateSnapshotReplay(cwd, args) {
405
434
  const votes = { pass: 0, block: 0 };
406
435
  let lastMissing = [];
407
436
  for (let i = 0; i < samples; i++) {
408
- const v = await judge(c.spec, c.edit, c.defers, { temperature: 0 });
437
+ const v = await judge(c.spec, c.edit, c.defers, c.resolved ?? [], { temperature: 0 });
409
438
  votes[v.verdict]++;
410
439
  lastMissing = v.missing;
411
440
  }
@@ -473,19 +502,36 @@ function cmdGateReview(cwd, args) {
473
502
  process.exit(1);
474
503
  }
475
504
  const beforeHash = curHash(cwd); // 변이 전 해시 = 게이트된 작업단위와 상관(M1 churn)
505
+ const specAdded = [];
506
+ const specDup = [];
476
507
  for (const c of toSpec) {
477
- addSpecCase(cwd, c);
478
- logCli(cwd, "spec-add", beforeHash);
508
+ if (addSpecCase(cwd, c)) {
509
+ logCli(cwd, "spec-add", beforeHash);
510
+ specAdded.push(c);
511
+ }
512
+ else {
513
+ specDup.push(c);
514
+ }
479
515
  }
516
+ const deferAdded = [];
517
+ const deferDup = [];
480
518
  for (const c of toDefer) {
481
- addDefer(cwd, c);
482
- logCli(cwd, "defer-add", beforeHash);
519
+ if (addDefer(cwd, c).added) {
520
+ logCli(cwd, "defer-add", beforeHash);
521
+ deferAdded.push(c);
522
+ }
523
+ else {
524
+ deferDup.push(c);
525
+ }
483
526
  }
484
527
  clearPendingReview(cwd);
485
- if (toSpec.length > 0)
486
- console.log(`🐢 명세 등록 ${toSpec.length}건: ${toSpec.join(", ")}`);
487
- if (toDefer.length > 0)
488
- console.log(`🐢 미룸 등록 ${toDefer.length}건: ${toDefer.join(", ")}`);
528
+ if (specAdded.length > 0)
529
+ console.log(`🐢 명세 등록 ${specAdded.length}건: ${specAdded.join(", ")}`);
530
+ if (deferAdded.length > 0)
531
+ console.log(`🐢 미룸 등록 ${deferAdded.length}건: ${deferAdded.join(", ")}`);
532
+ if (specDup.length + deferDup.length > 0) {
533
+ console.log(`🐢 중복 skip ${specDup.length + deferDup.length}건: ${[...specDup, ...deferDup].join(", ")}`);
534
+ }
489
535
  console.log("→ 검토 완료(펜딩 비움). 같은 편집을 재시도하면 등록된 케이스 기준으로 재판정됩니다.");
490
536
  }
491
537
  // ---------- gbc metrics ----------
@@ -682,8 +728,9 @@ function usage() {
682
728
  gbc defer unmute Stop defer 알림 다시 켜기
683
729
  gbc spec add "<케이스>" 승인된 시나리오를 .gbc/spec.md에 등록
684
730
  gbc spec show 등록된 케이스 목록
685
- gbc spec clear 명세 비우기(작업단위 종료)
686
- gbc gate reset 작업단위 게이트 리셋
731
+ gbc spec clear 명세 비우기(아카이브 없이)
732
+ gbc done 작업단위 명시 종료(명세 아카이브→비움 + 게이트 리셋)
733
+ gbc gate reset 작업단위 게이트만 리셋(명세 보존·같은 단위 재게이트)
687
734
  gbc gate review block이 도출한 누락 케이스 체크리스트 보기
688
735
  gbc gate review --spec <ref> --defer <ref>
689
736
  누락 케이스 일괄 분류(승인→spec / 미룸→defer)
@@ -725,6 +772,8 @@ async function main() {
725
772
  return cmdSpec(rest);
726
773
  case "gate":
727
774
  return cmdGate(rest);
775
+ case "done":
776
+ return cmdDone();
728
777
  case "metrics":
729
778
  return cmdMetrics(rest);
730
779
  case "repos":
package/dist/defer.js CHANGED
@@ -29,13 +29,23 @@ export function loadDefers(cwd) {
29
29
  function save(cwd, defers) {
30
30
  writeJson(deferPath(cwd), defers);
31
31
  }
32
- /** 명시적으로 항목을 미룬다 (침묵 누락 차단의 유일한 정당 경로) */
32
+ /**
33
+ * 명시적으로 항목을 미룬다 (침묵 누락 차단의 유일한 정당 경로).
34
+ * 중복 감지(ST2): 정규화 텍스트가 **미해결(open+in_progress)** 항목과 동일하면 새로 추가하지 않고
35
+ * 기존 엔트리를 added:false로 반환한다 — 같은 '무관' defer가 시점만 달리 누적되던 증상(2026-06-26 진단) 차단.
36
+ * resolved된 동일 텍스트는 막지 않는다(완료 후 같은 케이스가 정당히 재발할 수 있음 → 재-defer 허용).
37
+ * @returns { entry, added } — added=false면 entry는 기존(미해결) 항목.
38
+ */
33
39
  export function addDefer(cwd, item) {
34
40
  const defers = loadDefers(cwd);
35
- const entry = { item: normalizeCase(item), at: nowIso(), status: "open" };
41
+ const normalized = normalizeCase(item);
42
+ const dup = defers.find((d) => d.status !== "resolved" && d.item === normalized);
43
+ if (dup)
44
+ return { entry: dup, added: false };
45
+ const entry = { item: normalized, at: nowIso(), status: "open" };
36
46
  defers.push(entry);
37
47
  save(cwd, defers);
38
- return entry;
48
+ return { entry, added: true };
39
49
  }
40
50
  /**
41
51
  * 미해결(=resolved 아님) defer 항목 텍스트만 (게이트 판정 입력용).
@@ -50,6 +60,16 @@ export function activeDeferItems(cwd) {
50
60
  export function unresolvedDefers(cwd) {
51
61
  return loadDefers(cwd).filter((d) => d.status !== "resolved");
52
62
  }
63
+ /**
64
+ * 완료(resolved) defer 항목 텍스트만 (게이트 judge에 [이미 완료된 항목]으로 전달).
65
+ * activeDeferItems가 resolved를 제외해 judge에 안 보이던 갭을 메운다 — judge가 완료 케이스를
66
+ * "계획됨+미defer 형제"로 오인해 며칠 지난 케이스를 침묵누락 차단하던 드리프트(2026-06-26 진단) 완화.
67
+ */
68
+ export function resolvedDeferItems(cwd) {
69
+ return loadDefers(cwd)
70
+ .filter((d) => d.status === "resolved")
71
+ .map((d) => d.item);
72
+ }
53
73
  /**
54
74
  * ref 문자열로 전환 대상 엔트리를 고른다. 세 형태 지원:
55
75
  * - "all": eligibleFrom 상태에 해당하는 전부
package/dist/hook.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { isGatedTool, normalizeEdit } from "./normalize.js";
5
5
  import { loadPlanSpec, computeSpecHash } from "./spec.js";
6
6
  import { isGated, markGated } from "./state.js";
7
- import { activeDeferItems, loadDefers } from "./defer.js";
7
+ import { activeDeferItems, resolvedDeferItems, loadDefers } from "./defer.js";
8
8
  import { isStopHintMuted, isGoldenCapture } from "./config.js";
9
9
  import { loadRepos } from "./repos.js";
10
10
  import { writePendingReview } from "./review.js";
@@ -159,12 +159,15 @@ export async function runPreToolUse(ctx) {
159
159
  const { judge } = await import("./judge.js");
160
160
  const editText = normalizeEdit(toolName, input.tool_input ?? {});
161
161
  const defers = activeDeferItems(cwd);
162
+ // 완료된 케이스를 judge에 [이미 완료된 항목]으로 함께 전달 → 과거 작업단위의 resolved 케이스를
163
+ // "미처리 형제"로 오인해 재차단하던 드리프트 완화(2026-06-26). active와 상호배타(filter 분리).
164
+ const resolved = resolvedDeferItems(cwd);
162
165
  // ①신버전 캐시 자동 refresh(0.3.0) — 사용자가 'gbc status'를 안 쳐도 캐시가 최신이 되게.
163
166
  // judge(네트워크·≥1.5s)와 *병렬*로만 건다 → 핫패스 지연 0. cache-miss(여기 = judge 도는
164
167
  // 비-핫패스)에서만 stale일 때. cached-skip 핫패스엔 절대 네트워크 안 넣는다(0.2.7 원칙 보존).
165
168
  // refreshVersionCache는 내부 fail-silent(reject 불가)라 judge 경로를 깨지 않는다.
166
169
  const refreshP = shouldRefreshCache(Boolean(ctx?.cliPath)) ? refreshVersionCache() : null;
167
- const verdict = await judge(specText, editText, defers);
170
+ const verdict = await judge(specText, editText, defers, resolved);
168
171
  if (refreshP)
169
172
  await refreshP; // judge 동안 이미 완료 — 이 편집의 notice가 갱신된 캐시를 읽도록
170
173
  // 골든셋 캡처(A2, opt-in) — judge가 실제 평가한 cache-miss edit만, fail-open 제외(실판정 아님).
@@ -179,6 +182,7 @@ export async function runPreToolUse(ctx) {
179
182
  edit: editText,
180
183
  spec: specText,
181
184
  defers,
185
+ resolved,
182
186
  expected: { verdict: verdict.verdict, missing: verdict.missing, reason: verdict.reason },
183
187
  });
184
188
  }
@@ -307,7 +311,7 @@ export async function runStop() {
307
311
  * dead doc이 된다. 매 세션 컨텍스트에 신뢰성 있게 규약을 주입하는 유일한 결정론 채널이 이 문자열이다.
308
312
  * (hook엔 추론을 넣지 않는다 — 텍스트만. 자연어/대상 감지·전환 실행은 에이전트 측 책임.)
309
313
  */
310
- const DEFER_PROTOCOL = "규약 — 항목 착수 시 'gbc defer start <ref>'로 진행중 표시, 사용자가 완료를 명시하면 'gbc defer resolve <ref>'로 종결(신호가 모호하면 resolve하지 말고 확인). 되돌리기는 'gbc defer reopen <ref>'. ref=번호|텍스트|all. 모든 자동 전환은 사용자에게 표면화.";
314
+ const DEFER_PROTOCOL = "규약 — 항목 착수 시 'gbc defer start <ref>'로 진행중 표시, 사용자가 완료를 명시하면 'gbc defer resolve <ref>'로 종결(신호가 모호하면 resolve하지 말고 확인). 되돌리기는 'gbc defer reopen <ref>'. ref=번호|텍스트|all. 모든 자동 전환은 사용자에게 표면화. 작업단위(현재 명세) 전체가 끝나면 'gbc done'으로 명시 종료 — 명세를 아카이브·비우고 게이트를 리셋한다(이걸 안 하면 옛 케이스가 다음 작업단위에 형제로 부활).";
311
315
  /**
312
316
  * 전체 defer 리스트에서 미해결(open+in_progress)만 골라 상태 마커와 함께 한 줄씩 포맷한다.
313
317
  * ★ 번호는 전체-리스트 위치(인덱스+1)로 매긴다 — `gbc defer list`·`gbc defer <N>` 인덱스 ref와 동일.
package/dist/judge.js CHANGED
@@ -50,10 +50,11 @@ const GATE_SYSTEM = `너는 코드 구현 직전에 동작하는 "게이트"다.
50
50
 
51
51
  [2단계 — 동작 편집일 때만 검사]
52
52
  (a) [계획 명세]가 없거나 빈약해서 이 동작의 의도·시나리오가 미지정인 채 구현되고 있는가? → **block** (시나리오 미지정).
53
- (b) 계획 명세가 있다면: 이 편집이 작성/수정하는 *바로 그 기능*에 대해 계획에 적힌 형제 케이스 중, 이 편집에서도 안 다뤄지고 [명시적으로 미룬 항목]에도 없는 것이 있는가? → **block** (침묵 누락).
53
+ (b) 계획 명세가 있다면: 이 편집이 작성/수정하는 *바로 그 기능*에 대해 계획에 적힌 형제 케이스 중, 이 편집에서도 안 다뤄지고 [명시적으로 미룬 항목]에도 [이미 완료된 항목]에도 없는 것이 있는가? → **block** (침묵 누락).
54
54
  - 예: 로그인 검증 함수를 쓰면서 계획의 로그인 검증 케이스(중복 이메일·비밀번호 길이 등)를 언급·등록 없이 빠뜨림.
55
55
  - 코드 주석으로 "나중에"라고만 적고 미룬 항목에 등록 안 한 것도 침묵 누락이다.
56
56
  - 계획이 요구한 동작 형태(예: 인라인 에러 메시지)를 충족 못 하고 다른 형태(예: bool만 반환)로 빠뜨린 것도 누락이다.
57
+ - ★ [이미 완료된 항목]에 있는 케이스는 **이전 작업단위에서 이미 처리된 것**이다. 형제 후보에서 제외하고 절대 누락으로 다시 차단(re-flag)하지 마라. 이 편집과 무관한 과거 완료 케이스를 침묵누락이라 막는 것은 오탐이다.
57
58
  (c) 위에 해당 없으면 → **pass**.
58
59
 
59
60
  핵심 균형:
@@ -62,13 +63,16 @@ const GATE_SYSTEM = `너는 코드 구현 직전에 동작하는 "게이트"다.
62
63
 
63
64
  오직 아래 JSON만 출력(설명·마크다운 펜스 금지):
64
65
  {"verdict":"block"|"pass","missing":["누락된 케이스"],"reason":"한 줄 사유"}`;
65
- function buildUserMessage(planSpec, editText, defers) {
66
- const deferText = defers.length > 0 ? defers.map((d) => `- ${d}`).join("\n") : "(없음)";
66
+ function buildUserMessage(planSpec, editText, defers, resolved = []) {
67
+ const fmt = (xs) => (xs.length > 0 ? xs.map((d) => `- ${d}`).join("\n") : "(없음)");
67
68
  return `[계획 명세]
68
69
  ${planSpec.trim() || "(계획 명세 없음 — 개발자가 곧바로 구현을 시작함)"}
69
70
 
70
71
  [명시적으로 미룬 항목]
71
- ${deferText}
72
+ ${fmt(defers)}
73
+
74
+ [이미 완료된 항목]
75
+ ${fmt(resolved)}
72
76
 
73
77
  [현재 편집]
74
78
  ${editText}`;
@@ -174,8 +178,8 @@ function judgeViaCliWin(system, user) {
174
178
  * 게이트 판정. ANTHROPIC_API_KEY 있으면 직접 API(빠름), 없으면 claude -p 폴백.
175
179
  * 실패 시 안전하게 pass(fail-open) — 게이트가 개발을 막아버리는 사고 방지.
176
180
  */
177
- export async function judge(planSpec, editText, defers = [], opts = {}) {
178
- const user = buildUserMessage(planSpec, editText, defers);
181
+ export async function judge(planSpec, editText, defers = [], resolved = [], opts = {}) {
182
+ const user = buildUserMessage(planSpec, editText, defers, resolved);
179
183
  const transport = selectedTransport();
180
184
  try {
181
185
  // claude -p 폴백은 temperature 플래그가 없어 핀 불가 → CLI-transport replay는 best-effort.
package/dist/spec.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, appendFileSync, existsSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { gbcDir } from "./store.js";
@@ -56,16 +56,24 @@ function specPath(cwd) {
56
56
  * 케이스 한 줄을 .gbc/spec.md에 append. 파일 없으면 헤더와 함께 생성.
57
57
  * 입력은 한 줄로 정규화한다: 줄바꿈→공백(readSpecCases 단일라인 매칭과 정합),
58
58
  * 길이 상한 절단(에이전트가 멀티라인/장문 출력을 그대로 add해도 안전).
59
+ *
60
+ * 중복 감지(ST2): 정규화 텍스트가 기존 케이스와 동일하면 등록하지 않고 false 반환
61
+ * (drift 진단 2026-06-26의 2차 증상 — 같은 케이스가 시점만 달리 더미에 누적되던 것 차단).
62
+ * @returns 새로 등록했으면 true, 정규화 동일 케이스가 이미 있어 skip했으면 false.
59
63
  */
60
64
  export function addSpecCase(cwd, item) {
65
+ const normalized = normalizeCase(item);
66
+ if (readSpecCases(cwd).some((c) => c === normalized))
67
+ return false;
61
68
  const path = specPath(cwd);
62
- const line = `- [ ] ${normalizeCase(item)}\n`;
69
+ const line = `- [ ] ${normalized}\n`;
63
70
  if (existsSync(path)) {
64
71
  appendFileSync(path, line, "utf8");
65
72
  }
66
73
  else {
67
74
  writeFileSync(path, `# 작업 명세\n\n${line}`, "utf8");
68
75
  }
76
+ return true;
69
77
  }
70
78
  /** 현재 spec.md의 케이스(체크리스트 라인) 텍스트만 추출. */
71
79
  export function readSpecCases(cwd) {
@@ -84,3 +92,35 @@ export function readSpecCases(cwd) {
84
92
  export function clearSpec(cwd) {
85
93
  writeFileSync(specPath(cwd), "", "utf8");
86
94
  }
95
+ /** 파일명 안전 타임스탬프(ISO의 ':' '.'을 '-'로). Date 금지 환경 방어(빈 문자열 폴백). */
96
+ function nowStamp() {
97
+ try {
98
+ return new Date().toISOString().replace(/[:.]/g, "-");
99
+ }
100
+ catch {
101
+ return "nodate";
102
+ }
103
+ }
104
+ /**
105
+ * 작업단위 종료(ST3 — gbc done): spec.md 본문을 .gbc/spec.archive/<specHash>-<stamp>.md로
106
+ * 보존한 뒤 비운다. 본문이 비어 있으면 아카이브할 것이 없어 null 반환(clearSpec도 생략).
107
+ *
108
+ * drift 근본수정(2026-06-26): 시스템에 작업단위 "완료" 이벤트가 없어 spec.md가 append 전용으로
109
+ * 영구 누적되고, 과거 완료 케이스가 새 작업단위 등록 시 형제로 부활하던 결함을 닫는다. 이 함수가
110
+ * 그 명시적 완료 이벤트의 데이터 동작이다(게이트 리셋은 호출부 cmdDone이 별도로 — resetGate 불변).
111
+ * @returns 아카이브 파일 경로(보존했으면), 비울 본문이 없으면 null.
112
+ */
113
+ export function archiveSpec(cwd) {
114
+ const path = specPath(cwd);
115
+ if (!existsSync(path))
116
+ return null;
117
+ const raw = readFileSync(path, "utf8");
118
+ if (raw.trim() === "")
119
+ return null;
120
+ const dir = join(gbcDir(cwd), "spec.archive");
121
+ mkdirSync(dir, { recursive: true });
122
+ const archivePath = join(dir, `${computeSpecHash(raw)}-${nowStamp()}.md`);
123
+ writeFileSync(archivePath, raw, "utf8");
124
+ clearSpec(cwd);
125
+ return archivePath;
126
+ }
package/dist/version.js CHANGED
@@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  const PKG = "geobuke-code";
8
- const TTL_MS = 24 * 60 * 60 * 1000; // 24h
8
+ const TTL_MS = 12 * 60 * 60 * 1000; // 12h
9
9
  const FETCH_TIMEOUT_MS = 1500;
10
10
  export function cachePath(home = homedir()) {
11
11
  return join(home, ".gbc", "version-check.json");
@@ -51,7 +51,7 @@ export function writeVersionCache(cache, home) {
51
51
  /* 캐시 기록 실패는 무시(fail-silent) */
52
52
  }
53
53
  }
54
- /** 캐시가 없거나 TTL(24h) 초과면 stale. now는 주입 가능(테스트). */
54
+ /** 캐시가 없거나 TTL(12h) 초과면 stale. now는 주입 가능(테스트). */
55
55
  export function isCacheStale(cache, now = Date.now()) {
56
56
  if (!cache)
57
57
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geobuke-code",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "거북이코드 — 구현 직전 강제 게이트. Claude Code PreToolUse hook으로 코드 변경 전 계획 케이스 누락·시나리오 미지정을 차단한다.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gate
3
- description: 거북이코드 구현-전 게이트를 관리한다. 로드된 계획 명세 확인, 미룬(defer) 항목 등록·조회·해결, 작업단위 게이트 리셋. PreToolUse hook이 코드 변경을 차단했을 때 이 스킬로 케이스를 명시적으로 미루거나 게이트 상태를 점검한다. '/gate', '게이트 상태', '케이스 미루기', 'defer 등록', '게이트 리셋', '게이트가 막아' 등 언급 시 호출.
3
+ description: 거북이코드 구현-전 게이트를 관리한다. 로드된 계획 명세 확인, 미룬(defer) 항목 등록·조회·해결, 작업단위 게이트 리셋, 작업단위 명시 종료(gbc done). PreToolUse hook이 코드 변경을 차단했을 때 이 스킬로 케이스를 명시적으로 미루거나 게이트 상태를 점검한다. '/gate', '게이트 상태', '케이스 미루기', 'defer 등록', '게이트 리셋', '작업단위 종료', 'gbc done', '게이트가 막아' 등 언급 시 호출.
4
4
  ---
5
5
 
6
6
  # /gate — 구현-전 게이트 관리
@@ -44,8 +44,9 @@ defer 항목은 **open(미착수) → in_progress(진행중) → resolved(해결
44
44
  | 백로그로 되돌리기 (→ open) | `gbc defer reopen <번호\|텍스트\|all>` |
45
45
  | 승인된 시나리오를 명세에 등록 | `gbc spec add "<케이스>"` |
46
46
  | 등록된 케이스 목록 | `gbc spec show` |
47
- | 명세 비우기(작업단위 종료) | `gbc spec clear` |
48
- | 작업단위 게이트 리셋(다음 편집에서 재발동) | `gbc gate reset` |
47
+ | 명세 비우기(아카이브 없이) | `gbc spec clear` |
48
+ | **작업단위 명시 종료**(명세 아카이브→비움 + 게이트 리셋) | `gbc done` |
49
+ | 작업단위 게이트만 리셋(명세 보존·같은 단위 재게이트) | `gbc gate reset` |
49
50
  | block이 도출한 누락 케이스 체크리스트 보기 | `gbc gate review` |
50
51
  | 누락 케이스 일괄 분류(승인→spec / 미룸→defer) | `gbc gate review --spec <번호\|텍스트\|all> --defer <번호\|텍스트\|all>` |
51
52
  | 판정 골든셋 캡처 토글·조회 | `gbc gate snapshot <on\|off\|status\|list\|clear>` |
@@ -66,6 +67,7 @@ defer 항목은 **open(미착수) → in_progress(진행중) → resolved(해결
66
67
  4. 재시도하면 통과한다.
67
68
  > 시나리오 도출은 코딩 에이전트 본체(Opus)가 대화 맥락으로, 게이트 판정은 haiku가 — 두 작업/두 모델 분리(gbc는 모델 계층을 소유하지 않는다).
68
69
  3. **세션 종료 시**: Stop hook이 미해결 defer를 "진행중 N · 미착수 M"으로 구분 리마인드한다. `gbc defer list`로 확인하고, 사용자 완료 선언이 있었으면 resolve, 아니면 다음 세션으로 의식적으로 이월한다(진행중 항목은 in_progress 그대로 남아 다음 SessionStart에 표면화).
70
+ 4. **작업단위(현재 명세) 전체가 끝났을 때 — `gbc done`으로 명시 종료**: 명세(`.gbc/spec.md`)를 `.gbc/spec.archive/`로 보존한 뒤 비우고 게이트를 리셋한다. **이걸 하지 않으면** 며칠 뒤 무관한 새 작업을 시작할 때 옛 명세의 미체크 케이스가 "현재 작업의 형제 케이스"로 부활해 침묵 누락 오탐을 낸다(2026-06-26 진단·근본수정). `gbc spec clear`는 아카이브 없이 비우기만, `gbc gate reset`은 명세를 **보존**한 채 같은 단위를 재게이트하는 별개 동작이다 — 작업단위를 끝낼 땐 `gbc done`을 쓴다.
69
71
 
70
72
  ## 명세 소스
71
73
 
@@ -80,6 +82,8 @@ defer 항목은 **open(미착수) → in_progress(진행중) → resolved(해결
80
82
 
81
83
  - **주석 defer는 defer가 아니다.** `// 비밀번호 검증은 다음에` 같은 코드 주석은 게이트가 침묵 누락으로 본다. 반드시 `gbc defer add`로 레지스트리에 등록해야 한다.
82
84
  - **게이트는 작업단위당 1회만 발동한다.** 명세가 바뀌거나 명세 밖 파일을 편집할 때 재발동한다. 강제로 다시 점검하려면 `gbc gate reset`.
85
+ - **작업단위를 끝내면 `gbc done`을 호출한다 — 안 하면 옛 케이스가 부활한다.** 명세는 append로만 누적되고 작업단위 "완료" 이벤트가 없어, 끝난 케이스가 `.gbc/spec.md`에 미체크로 남으면 다음 작업단위 등록 시 형제 케이스로 재차단된다(드리프트 오탐). `gbc done`이 명세를 아카이브·비워 이 누적을 끊는다. (완화책으로 0.4.2부터 resolved된 defer는 judge에 `[이미 완료된 항목]`으로 전달돼 재플래그를 줄이지만, 근본 정리는 `gbc done`이다.)
86
+ - **같은 케이스 중복 등록은 자동 skip된다(0.4.2).** `gbc spec add`/`gbc defer add`/`gbc gate review`가 정규화 동일 케이스(미해결)를 다시 등록하려 하면 "중복 등록 skip"으로 알리고 더미를 키우지 않는다. resolved된 항목의 동일 텍스트 재등록은 정당한 재-defer로 허용된다.
83
87
  - **게이트가 한 repo에서 아예 안 먹는다면** hook이 미등록·구식일 수 있다. `gbc repos list`가 등록된 각 repo의 게이트 건강성(`⚠️게이트hook부재`/`⚠️SessionStart누락`)을 표시한다 — 떴으면 그 repo에서 `gbc init --yes` 재실행. (크로스-repo는 hook 등록 여부만 검사하고 명령 freshness는 각 repo `gbc status`로 확인.)
84
88
  - **`--no-gate` / `GBC_NO_GATE=1` 우회는 계측된다.** 우회 자체가 게이트 가치 측정 데이터가 된다.
85
89
  - **판정 드리프트가 의심되면**(모델/gbc 업그레이드 후 게이트가 전과 다르게 군다) `gbc gate snapshot on`으로 한동안 캡처하고, 나중에 `gbc gate snapshot replay`로 재판정해 pass↔block 뒤집힘을 점검한다. 골든셋은 **편집 본문을 로컬 `.gbc/golden.json`에만** 저장한다(gitignore·로컬 pre-flight 전용 — 커밋하면 privacy 불변식 위반).