geobuke-code 0.2.4 → 0.2.6
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 +11 -8
- package/dist/cli.js +66 -23
- package/dist/config.js +18 -0
- package/dist/defer.js +70 -21
- package/dist/hook.js +62 -16
- package/package.json +1 -1
- package/skills/gate/SKILL.md +28 -6
- package/skills/gbc-mute/SKILL.md +38 -0
package/README.md
CHANGED
|
@@ -96,13 +96,13 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
96
96
|
|
|
97
97
|
| 시점 | hook (matcher) | 동작 |
|
|
98
98
|
|---|---|---|
|
|
99
|
-
| **세션 시작·재개** | SessionStart (`startup\|resume`) | `.gbc/defers.json`의 미해결 항목을 표면화(이전 작업 잔여 환기). 잔여 없으면 무출력. `compact`엔 발화 안 함(노이즈 방지) |
|
|
99
|
+
| **세션 시작·재개** | SessionStart (`startup\|resume`) | `.gbc/defers.json`의 미해결 항목을 "진행중 N · 미착수 M"로 구분 표면화(이전 작업 잔여 환기). 잔여 없으면 무출력. `compact`엔 발화 안 함(노이즈 방지) |
|
|
100
100
|
| **코드 변경 직전** | PreToolUse (`Edit\|Write\|MultiEdit`) | 명세 ↔ 변경 ↔ defer 대조 → 통과(침묵)/차단(시나리오 도출 지시)/fail-open |
|
|
101
101
|
| **작업단위당 1회** | (PreToolUse 캐시) | 같은 명세 해시 내에선 첫 편집만 판정, 이후 통과 → 매 편집 지연 회피 |
|
|
102
|
-
| **응답 종료** | Stop | 계측 flush(`events.jsonl`) |
|
|
102
|
+
| **응답 종료** | Stop | 계측 flush(`events.jsonl`) + 미해결 defer가 있으면 리마인드(매 대화 종료마다). 거슬리면 `gbc defer mute`(또는 `/gbc-mute` 스킬)로 끈다 — SessionStart 진입 알림은 유지 |
|
|
103
103
|
| **업데이트 필요 시** | (PreToolUse·SessionStart) | hook 구버전(②) 또는 신버전 출시(①)면 갱신 안내. PreToolUse는 세션당 1회(`systemMessage` 비차단), SessionStart는 진입 시 표시. `gbc status`는 캐시만 갱신하고 안내는 **표시하지 않는다**(명시 진단 명령). 게이트 통과/차단 동작은 불변 |
|
|
104
104
|
|
|
105
|
-
> 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 업데이트 안내만 끄려면 `GBC_NO_UPDATE_NOTICE=1`.
|
|
105
|
+
> 세션 진입 알림만 끄려면 `GBC_NO_SESSION_HINT=1`. 매 대화 종료(Stop) defer 리마인드만 끄려면 `gbc defer mute`(영속, 해제 `unmute` · 스킬 `/gbc-mute`) — 진입 알림은 남는다. 업데이트 안내만 끄려면 `GBC_NO_UPDATE_NOTICE=1`.
|
|
106
106
|
> 프로젝트 hook이 구식이거나(SessionStart 누락·옛 명령) 새 버전이 나오면 gbc가 감지해 `gbc init --yes` 재실행 또는 `npm i -g geobuke-code@latest`를 안내한다. PreToolUse 경로로도 알리므로 "설치만 하고 init 안 한" 경우에도 도달한다.
|
|
107
107
|
> **업데이트 안내(①)는 네트워크를 게이트 핫패스에 들이지 않는다**: `~/.gbc/version-check.json` 캐시만 비교하고, 갱신은 SessionStart·`gbc status`에서만 짧은 타임아웃으로. 조회 실패는 조용히 무시(fail-silent)되어 게이트 결정에 영향이 없다.
|
|
108
108
|
|
|
@@ -134,11 +134,14 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
134
134
|
|
|
135
135
|
| 명령 | 설명 |
|
|
136
136
|
|---|---|
|
|
137
|
-
| `gbc init` | hook +
|
|
138
|
-
| `gbc status` | 게이트 상태 + 로드된 명세
|
|
139
|
-
| `gbc defer add "<케이스>"` | 케이스를 명시적으로 미루기 |
|
|
140
|
-
| `gbc defer list` | 미룬 항목 목록 |
|
|
141
|
-
| `gbc defer
|
|
137
|
+
| `gbc init` | hook + `/gate` · `/gbc-mute` 스킬 설치 |
|
|
138
|
+
| `gbc status` | 게이트 상태 + 로드된 명세 + Stop 리마인드 음소거 여부 |
|
|
139
|
+
| `gbc defer add "<케이스>"` | 케이스를 명시적으로 미루기 (→ open) |
|
|
140
|
+
| `gbc defer list` | 미룬 항목 목록 (상태: 미해결/진행중/해결) |
|
|
141
|
+
| `gbc defer start <번호\|텍스트\|all>` | 착수 표시 (open → 진행중) |
|
|
142
|
+
| `gbc defer resolve <번호\|텍스트\|all>` | 종결 표시 (→ 해결) |
|
|
143
|
+
| `gbc defer reopen <번호\|텍스트\|all>` | 백로그로 되돌리기 (→ open) |
|
|
144
|
+
| `gbc defer mute` / `unmute` | 대화 종료(Stop)마다 뜨는 defer 리마인드 끄기/켜기 (영속) · 스킬: `/gbc-mute` |
|
|
142
145
|
| `gbc spec add "<케이스>"` | 승인된 시나리오를 `.gbc/spec.md`에 등록 |
|
|
143
146
|
| `gbc spec show` | 등록된 케이스 목록 |
|
|
144
147
|
| `gbc spec clear` | 명세 비우기(작업단위 종료) |
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,8 @@ import { mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync, } fro
|
|
|
7
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
|
-
import { addDefer, loadDefers, resolveDefer } from "./defer.js";
|
|
10
|
+
import { addDefer, loadDefers, resolveDefer, startDefer, reopenDefer } from "./defer.js";
|
|
11
|
+
import { isStopHintMuted, setStopHintMuted } from "./config.js";
|
|
11
12
|
import { selectedTransport } from "./judge.js";
|
|
12
13
|
import { buildPreCommand, normalizeHooks, ensureSessionStartHook } from "./install.js";
|
|
13
14
|
import { isCacheStale, readVersionCache, refreshVersionCache, } from "./version.js";
|
|
@@ -65,15 +66,15 @@ async function cmdInit(args) {
|
|
|
65
66
|
const yes = args.includes("--yes") || args.includes("-y");
|
|
66
67
|
const claudeDir = join(cwd, ".claude");
|
|
67
68
|
const settingsPath = join(claudeDir, "settings.json");
|
|
68
|
-
|
|
69
|
-
const
|
|
69
|
+
// 설치 대상 스킬들(제품소스 skills/<name>/SKILL.md → .claude/skills/<name>/SKILL.md).
|
|
70
|
+
const skillNames = ["gate", "gbc-mute"];
|
|
70
71
|
if (!yes) {
|
|
71
72
|
console.log(`🐢 gbc init — 다음을 수행합니다 (프로젝트 로컬만, 전역 ~/.claude 미변경):
|
|
72
73
|
|
|
73
74
|
대상 프로젝트: ${cwd}
|
|
74
75
|
1) ${settingsPath} 에 PreToolUse(Edit|Write) + Stop + SessionStart hook 추가 (머지·멱등)
|
|
75
76
|
- 기존 settings.json 있으면 백업: settings.json.bak-<시각>
|
|
76
|
-
2) ${join(
|
|
77
|
+
2) ${join(claudeDir, "skills")} 에 ${skillNames.map((n) => `/${n}`).join(", ")} 스킬 설치
|
|
77
78
|
3) hook 명령: ${buildPreCommand(CLI_PATH)}
|
|
78
79
|
${hasApiKey()
|
|
79
80
|
? " (~/.gbc/api-key 감지됨 → 빠른 haiku API 경로로 동작)"
|
|
@@ -82,7 +83,7 @@ ${hasApiKey()
|
|
|
82
83
|
`);
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
|
-
mkdirSync(
|
|
86
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
86
87
|
// settings.json 머지
|
|
87
88
|
let settings = {};
|
|
88
89
|
if (existsSync(settingsPath)) {
|
|
@@ -132,10 +133,15 @@ ${hasApiKey()
|
|
|
132
133
|
console.log(` = SessionStart hook 이미 존재 (skip)`);
|
|
133
134
|
}
|
|
134
135
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
// 스킬 설치 (gate + gbc-mute)
|
|
137
|
+
for (const name of skillNames) {
|
|
138
|
+
const src = join(PKG_ROOT, "skills", name, "SKILL.md");
|
|
139
|
+
if (existsSync(src)) {
|
|
140
|
+
const destDir = join(claudeDir, "skills", name);
|
|
141
|
+
mkdirSync(destDir, { recursive: true });
|
|
142
|
+
copyFileSync(src, join(destDir, "SKILL.md"));
|
|
143
|
+
console.log(` + /${name} 스킬 설치`);
|
|
144
|
+
}
|
|
139
145
|
}
|
|
140
146
|
const transport = selectedTransport();
|
|
141
147
|
console.log(`
|
|
@@ -170,16 +176,20 @@ async function cmdStatus() {
|
|
|
170
176
|
const hash = computeSpecHash(text);
|
|
171
177
|
const state = loadState(cwd);
|
|
172
178
|
const defers = loadDefers(cwd);
|
|
173
|
-
const unresolved = defers.filter((d) =>
|
|
179
|
+
const unresolved = defers.filter((d) => d.status !== "resolved");
|
|
180
|
+
const inProgress = defers.filter((d) => d.status === "in_progress").length;
|
|
174
181
|
console.log(`🐢 거북이 게이트 상태 — ${cwd}
|
|
175
182
|
버전: ${PKG_VERSION || "(불명)"}
|
|
176
183
|
트랜스포트: ${selectedTransport()}
|
|
177
184
|
명세 소스: ${source} ${text ? `(${text.length}자)` : "(비어있음 → 모든 코드변경 차단)"}
|
|
178
185
|
명세 해시: ${hash}
|
|
179
186
|
작업단위 게이트: ${state && state.specHash === hash && state.gated ? "통과됨(이 단위 재게이트 안 함)" : "미통과(다음 편집에서 발동)"}
|
|
180
|
-
defer: 전체 ${defers.length} / 미해결 ${unresolved.length}
|
|
187
|
+
defer: 전체 ${defers.length} / 미해결 ${unresolved.length} (진행중 ${inProgress} · 미착수 ${unresolved.length - inProgress})
|
|
188
|
+
Stop 리마인드: ${isStopHintMuted(cwd) ? "🔕 음소거 (해제: /gbc-mute)" : "🔔 켜짐"}`);
|
|
181
189
|
if (unresolved.length > 0) {
|
|
182
|
-
console.log(unresolved
|
|
190
|
+
console.log(unresolved
|
|
191
|
+
.map((d, i) => ` ${i + 1}. ${d.status === "in_progress" ? "▶[진행중] " : ""}${d.item}`)
|
|
192
|
+
.join("\n"));
|
|
183
193
|
}
|
|
184
194
|
// 신버전 업데이트 안내(buildVersionNotice)는 여기서 출력하지 않는다 — 안내 자리는
|
|
185
195
|
// SessionStart·PreToolUse 자동 채널 전용이고, status는 명시 진단 명령이라 나그 부적절.
|
|
@@ -199,23 +209,52 @@ function cmdDefer(args) {
|
|
|
199
209
|
logCli(cwd, "defer-add", curHash(cwd));
|
|
200
210
|
console.log(`🐢 미룸 등록: ${item}`);
|
|
201
211
|
}
|
|
212
|
+
else if (sub === "mute" || sub === "unmute") {
|
|
213
|
+
const muted = sub === "mute";
|
|
214
|
+
setStopHintMuted(cwd, muted);
|
|
215
|
+
if (muted) {
|
|
216
|
+
console.log("🔕 Stop 리마인드 음소거됨 — 대화 종료마다 뜨던 defer 알림을 끕니다.\n" +
|
|
217
|
+
" (SessionStart 진입 시엔 계속 표시 · 해제는 'gbc defer unmute')");
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
console.log("🔔 Stop 리마인드 음소거 해제됨 — 대화 종료 시 미해결 defer 알림이 다시 표시됩니다.");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
202
223
|
else if (sub === "list") {
|
|
203
224
|
const defers = loadDefers(cwd);
|
|
225
|
+
if (isStopHintMuted(cwd)) {
|
|
226
|
+
console.log("🔕 Stop 리마인드 음소거 중 (해제: gbc defer unmute)");
|
|
227
|
+
}
|
|
204
228
|
if (defers.length === 0) {
|
|
205
229
|
console.log("(미룬 항목 없음)");
|
|
206
230
|
return;
|
|
207
231
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
const label = {
|
|
233
|
+
open: "미해결",
|
|
234
|
+
in_progress: "진행중",
|
|
235
|
+
resolved: "해결",
|
|
236
|
+
};
|
|
237
|
+
defers.forEach((d, i) => console.log(`${i + 1}. [${label[d.status]}] ${d.item}`));
|
|
238
|
+
}
|
|
239
|
+
else if (sub === "start" || sub === "resolve" || sub === "reopen") {
|
|
211
240
|
const ref = args.slice(1).join(" ").trim();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
241
|
+
if (!ref) {
|
|
242
|
+
console.error(`사용: gbc defer ${sub} <번호|텍스트|all>`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
const fn = sub === "start" ? startDefer : sub === "resolve" ? resolveDefer : reopenDefer;
|
|
246
|
+
const verb = sub === "start" ? "착수" : sub === "resolve" ? "해결" : "되돌림(open)";
|
|
247
|
+
const changed = fn(cwd, ref);
|
|
248
|
+
if (changed.length > 0) {
|
|
249
|
+
logCli(cwd, `defer-${sub}`, curHash(cwd));
|
|
250
|
+
console.log(`🐢 ${verb} ${changed.length}건: ${changed.map((d) => d.item).join(", ")}`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
console.log(`매칭되는 항목 없음(0건): ${ref}`);
|
|
254
|
+
}
|
|
216
255
|
}
|
|
217
256
|
else {
|
|
218
|
-
console.error("사용: gbc defer <add|list|resolve> ...");
|
|
257
|
+
console.error("사용: gbc defer <add|list|start|resolve|reopen|mute|unmute> ...");
|
|
219
258
|
process.exit(1);
|
|
220
259
|
}
|
|
221
260
|
}
|
|
@@ -296,9 +335,13 @@ function usage() {
|
|
|
296
335
|
사용:
|
|
297
336
|
gbc init [--yes] 프로젝트에 hook + /gate 스킬 설치
|
|
298
337
|
gbc status 게이트 상태 + 로드된 명세 확인
|
|
299
|
-
gbc defer add "<케이스>" 케이스를 명시적으로 미루기
|
|
300
|
-
gbc defer list 미룬 항목 목록
|
|
301
|
-
gbc defer
|
|
338
|
+
gbc defer add "<케이스>" 케이스를 명시적으로 미루기 (→ open)
|
|
339
|
+
gbc defer list 미룬 항목 목록 (상태: 미해결/진행중/해결)
|
|
340
|
+
gbc defer start <번호|텍스트|all> 착수 표시 (open → 진행중)
|
|
341
|
+
gbc defer resolve <번호|텍스트|all> 종결 표시 (→ 해결; 항상 사용자 점검 후)
|
|
342
|
+
gbc defer reopen <번호|텍스트|all> 백로그로 되돌리기 (→ open)
|
|
343
|
+
gbc defer mute 대화 종료(Stop)마다 뜨는 defer 알림 끄기 (영속)
|
|
344
|
+
gbc defer unmute Stop defer 알림 다시 켜기
|
|
302
345
|
gbc spec add "<케이스>" 승인된 시나리오를 .gbc/spec.md에 등록
|
|
303
346
|
gbc spec show 등록된 케이스 목록
|
|
304
347
|
gbc spec clear 명세 비우기(작업단위 종료)
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { gbcDir, readJson, writeJson } from "./store.js";
|
|
3
|
+
function configPath(cwd) {
|
|
4
|
+
return join(gbcDir(cwd), "config.json");
|
|
5
|
+
}
|
|
6
|
+
function readConfig(cwd) {
|
|
7
|
+
return readJson(configPath(cwd), {});
|
|
8
|
+
}
|
|
9
|
+
/** Stop hook defer 리마인드가 음소거 상태인지. 파일/키 부재 시 false(기본=노출). */
|
|
10
|
+
export function isStopHintMuted(cwd) {
|
|
11
|
+
return readConfig(cwd).stopHintMuted === true;
|
|
12
|
+
}
|
|
13
|
+
/** Stop hook defer 리마인드 음소거 토글을 영속 저장(수동 unmute 전까지 유지). */
|
|
14
|
+
export function setStopHintMuted(cwd, muted) {
|
|
15
|
+
const cfg = readConfig(cwd);
|
|
16
|
+
cfg.stopHintMuted = muted;
|
|
17
|
+
writeJson(configPath(cwd), cfg);
|
|
18
|
+
}
|
package/dist/defer.js
CHANGED
|
@@ -12,44 +12,93 @@ function nowIso() {
|
|
|
12
12
|
return "";
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* 원시 엔트리를 status 단일 소스로 정규화한다(마이그레이션).
|
|
17
|
+
* 옛 0.2.4 이하 포맷 {resolved:boolean}을 읽을 때 status로 승격:
|
|
18
|
+
* resolved:true→"resolved", false/부재→"open". 이미 status가 있으면 그대로.
|
|
19
|
+
*/
|
|
20
|
+
function promote(raw) {
|
|
21
|
+
const status = raw.status ?? (raw.resolved === true ? "resolved" : "open");
|
|
22
|
+
return { item: raw.item, at: raw.at, status };
|
|
23
|
+
}
|
|
24
|
+
/** 디스크에서 defer 엔트리를 읽어 status 포맷으로 정규화 반환(읽기 시 자동 승격) */
|
|
15
25
|
export function loadDefers(cwd) {
|
|
16
|
-
return readJson(deferPath(cwd), []);
|
|
26
|
+
return readJson(deferPath(cwd), []).map(promote);
|
|
17
27
|
}
|
|
28
|
+
/** 저장은 항상 status 포맷으로 통일 — promote가 옛 resolved 필드를 떨궈 단일 소스 보장(drift 방지) */
|
|
18
29
|
function save(cwd, defers) {
|
|
19
30
|
writeJson(deferPath(cwd), defers);
|
|
20
31
|
}
|
|
21
32
|
/** 명시적으로 항목을 미룬다 (침묵 누락 차단의 유일한 정당 경로) */
|
|
22
33
|
export function addDefer(cwd, item) {
|
|
23
34
|
const defers = loadDefers(cwd);
|
|
24
|
-
const entry = { item: normalizeCase(item), at: nowIso(),
|
|
35
|
+
const entry = { item: normalizeCase(item), at: nowIso(), status: "open" };
|
|
25
36
|
defers.push(entry);
|
|
26
37
|
save(cwd, defers);
|
|
27
38
|
return entry;
|
|
28
39
|
}
|
|
29
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* 미해결(=resolved 아님) defer 항목 텍스트만 (게이트 판정 입력용).
|
|
42
|
+
* gate-neutral: open + in_progress 모두 '아직 안 끝난 의도적 미룸'으로 judge에 전달 → 차단 로직 무변경.
|
|
43
|
+
*/
|
|
30
44
|
export function activeDeferItems(cwd) {
|
|
31
45
|
return loadDefers(cwd)
|
|
32
|
-
.filter((d) =>
|
|
46
|
+
.filter((d) => d.status !== "resolved")
|
|
33
47
|
.map((d) => d.item);
|
|
34
48
|
}
|
|
35
|
-
/** 미해결 defer 엔트리 (Stop hook 리마인드용) */
|
|
49
|
+
/** 미해결(open+in_progress) defer 엔트리 (Stop hook·SessionStart 리마인드용) */
|
|
36
50
|
export function unresolvedDefers(cwd) {
|
|
37
|
-
return loadDefers(cwd).filter((d) =>
|
|
51
|
+
return loadDefers(cwd).filter((d) => d.status !== "resolved");
|
|
38
52
|
}
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
/**
|
|
54
|
+
* ref 문자열로 전환 대상 엔트리를 고른다. 세 형태 지원:
|
|
55
|
+
* - "all": eligibleFrom 상태에 해당하는 전부
|
|
56
|
+
* - 공백구분 토큰이 전부 정수: 복수 인덱스(1-base). 인덱스는 명시 지정이라 적격 무시(사용자가 번호를 안다)
|
|
57
|
+
* - 그 외: 통째로 부분 텍스트 1건 매칭(적격 항목 중) — 공백 포함 문구 하위호환
|
|
58
|
+
*/
|
|
59
|
+
function selectTargets(defers, ref, eligibleFrom) {
|
|
60
|
+
const trimmed = ref.trim();
|
|
61
|
+
// 빈 ref 가드: includes("")는 항상 첫 항목을 매칭하므로 빈 문자열이 엉뚱한 항목을 고른다.
|
|
62
|
+
// CLI(cli.ts)는 이미 빈 ref를 사전 차단하지만, selectTargets가 라이브러리로 직접 호출될 때를 위한 방어.
|
|
63
|
+
if (trimmed === "")
|
|
64
|
+
return [];
|
|
65
|
+
const eligible = (d) => eligibleFrom.includes(d.status);
|
|
66
|
+
if (trimmed === "all")
|
|
67
|
+
return defers.filter(eligible);
|
|
68
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
69
|
+
const allInts = tokens.length > 0 && tokens.every((t) => /^\d+$/.test(t));
|
|
70
|
+
if (allInts) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const t of tokens) {
|
|
73
|
+
const idx = Number.parseInt(t, 10);
|
|
74
|
+
if (idx >= 1 && idx <= defers.length && !out.includes(defers[idx - 1])) {
|
|
75
|
+
out.push(defers[idx - 1]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
46
79
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
80
|
+
const t = defers.find((d) => eligible(d) && d.item.includes(trimmed));
|
|
81
|
+
return t ? [t] : [];
|
|
82
|
+
}
|
|
83
|
+
/** 선택된 대상을 toStatus로 전환하고 저장. 전환된 엔트리 배열 반환(매칭 0건이면 빈 배열). */
|
|
84
|
+
function transition(cwd, ref, toStatus, eligibleFrom) {
|
|
85
|
+
const defers = loadDefers(cwd);
|
|
86
|
+
const targets = selectTargets(defers, ref, eligibleFrom);
|
|
87
|
+
for (const t of targets)
|
|
88
|
+
t.status = toStatus;
|
|
89
|
+
if (targets.length > 0)
|
|
90
|
+
save(cwd, defers);
|
|
91
|
+
return targets;
|
|
92
|
+
}
|
|
93
|
+
/** open → in_progress (착수). 텍스트/all 적격 = open. 인덱스는 명시 지정. */
|
|
94
|
+
export function startDefer(cwd, ref) {
|
|
95
|
+
return transition(cwd, ref, "in_progress", ["open"]);
|
|
96
|
+
}
|
|
97
|
+
/** → resolved (종결, 항상 사람 선언). 텍스트/all 적격 = open + in_progress. */
|
|
98
|
+
export function resolveDefer(cwd, ref) {
|
|
99
|
+
return transition(cwd, ref, "resolved", ["open", "in_progress"]);
|
|
100
|
+
}
|
|
101
|
+
/** → open (백로그로 되돌리기: 보류/이월 또는 잘못된 resolve 취소). 텍스트/all 적격 = in_progress + resolved. */
|
|
102
|
+
export function reopenDefer(cwd, ref) {
|
|
103
|
+
return transition(cwd, ref, "open", ["in_progress", "resolved"]);
|
|
55
104
|
}
|
package/dist/hook.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
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,
|
|
7
|
+
import { activeDeferItems, loadDefers } from "./defer.js";
|
|
8
|
+
import { isStopHintMuted } from "./config.js";
|
|
8
9
|
import { readProjectSettings, buildUpdateNotice, wasNotified, markNotified } from "./notice.js";
|
|
9
10
|
import { isCacheStale, readVersionCache, refreshVersionCache } from "./version.js";
|
|
10
11
|
import { appendFileSync } from "node:fs";
|
|
@@ -233,31 +234,69 @@ export async function runStop() {
|
|
|
233
234
|
if (input.stop_hook_active === true)
|
|
234
235
|
process.exit(0);
|
|
235
236
|
const cwd = input.cwd || process.cwd();
|
|
237
|
+
// 사용자가 'gbc defer mute'로 Stop 리마인드를 음소거했으면 조용히 통과(unmute 전까지 영속).
|
|
238
|
+
// SessionStart 진입 알림은 별개 채널이라 영향 없음. emit 없이 종료 = Claude 정상 stop 허용.
|
|
239
|
+
if (isStopHintMuted(cwd))
|
|
240
|
+
process.exit(0);
|
|
236
241
|
// defers.json 없으면(파일 부재) 조용히 통과
|
|
237
242
|
if (loadDefers(cwd).length === 0)
|
|
238
243
|
process.exit(0);
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
244
|
+
const all = loadDefers(cwd);
|
|
245
|
+
if (all.filter((d) => d.status !== "resolved").length === 0)
|
|
241
246
|
process.exit(0);
|
|
242
|
-
|
|
243
|
-
emit({
|
|
244
|
-
decision: "block",
|
|
245
|
-
reason: `🐢 미해결 defer ${un.length}건이 남아 있습니다:\n${items}\n` +
|
|
246
|
-
`해결했으면 'gbc defer resolve <번호>', 다음 세션으로 이월할 거면 의식적으로 확인하세요. ` +
|
|
247
|
-
`(이 리마인드는 1회만 표시됩니다.)`,
|
|
248
|
-
});
|
|
247
|
+
emit({ decision: "block", reason: buildStopReminder(all) });
|
|
249
248
|
process.exit(0);
|
|
250
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* defer 전환 행동 규약 — SessionStart/Stop 알림 문자열에 임베드한다.
|
|
252
|
+
* 규약 발화 자리가 hint 문자열인 이유: SKILL.md는 skill 실행 시점에만 읽혀 자유 편집·대화 중엔
|
|
253
|
+
* dead doc이 된다. 매 세션 컨텍스트에 신뢰성 있게 규약을 주입하는 유일한 결정론 채널이 이 문자열이다.
|
|
254
|
+
* (hook엔 추론을 넣지 않는다 — 텍스트만. 자연어/대상 감지·전환 실행은 에이전트 측 책임.)
|
|
255
|
+
*/
|
|
256
|
+
const DEFER_PROTOCOL = "규약 — 항목 착수 시 'gbc defer start <ref>'로 진행중 표시, 사용자가 완료를 명시하면 'gbc defer resolve <ref>'로 종결(신호가 모호하면 resolve하지 말고 확인). 되돌리기는 'gbc defer reopen <ref>'. ref=번호|텍스트|all. 모든 자동 전환은 사용자에게 표면화.";
|
|
257
|
+
/**
|
|
258
|
+
* 전체 defer 리스트에서 미해결(open+in_progress)만 골라 상태 마커와 함께 한 줄씩 포맷한다.
|
|
259
|
+
* ★ 번호는 전체-리스트 위치(인덱스+1)로 매긴다 — `gbc defer list`·`gbc defer <N>` 인덱스 ref와 동일.
|
|
260
|
+
* 부분집합 번호를 쓰면 resolved가 앞에 있을 때 표시 번호 ≠ 실제 인덱스가 되어 엉뚱한 항목을 친다.
|
|
261
|
+
*/
|
|
262
|
+
function formatDeferList(all) {
|
|
263
|
+
return all
|
|
264
|
+
.map((d, i) => ({ d, n: i + 1 }))
|
|
265
|
+
.filter((x) => x.d.status !== "resolved")
|
|
266
|
+
.map((x) => `${x.n}. ${x.d.status === "in_progress" ? "▶[진행중]" : "[미착수]"} ${x.d.item}`)
|
|
267
|
+
.join("\n");
|
|
268
|
+
}
|
|
269
|
+
/** 미해결 건수를 진행중/미착수로 분해한 머리말 조각 */
|
|
270
|
+
function statusBreakdown(unresolved) {
|
|
271
|
+
const inProgress = unresolved.filter((d) => d.status === "in_progress").length;
|
|
272
|
+
return `진행중 ${inProgress} · 미착수 ${unresolved.length - inProgress}`;
|
|
273
|
+
}
|
|
251
274
|
/**
|
|
252
275
|
* 세션 진입(startup|resume) 시 미해결 defer 잔여를 표면화하는 알림 문자열. 없으면 "".
|
|
276
|
+
* 입력은 전체 defer 리스트(loadDefers) — 표시 번호를 전체-인덱스로 맞추기 위함(인덱스 ref 정합).
|
|
253
277
|
* gbc 자기 소유 데이터(.gbc/defers.json)만 사용 — scratch/메모리 미접근(다른 하네스와 혼재·환각 방지).
|
|
278
|
+
* in_progress를 open과 구분 표면화("진행중 N · 미착수 M") — 착수했지만 미종결 항목이 잊히지 않게.
|
|
254
279
|
*/
|
|
255
|
-
export function buildSessionStartHint(
|
|
280
|
+
export function buildSessionStartHint(all) {
|
|
281
|
+
const unresolved = all.filter((d) => d.status !== "resolved");
|
|
256
282
|
if (unresolved.length === 0)
|
|
257
283
|
return "";
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
`필요하면 사용자에게 이어서 처리할지 확인하세요.
|
|
284
|
+
return (`🐢 거북이 게이트 — 미해결 defer ${unresolved.length}건 (${statusBreakdown(unresolved)}, 이전 작업 잔여):\n` +
|
|
285
|
+
`${formatDeferList(all)}\n` +
|
|
286
|
+
`필요하면 사용자에게 이어서 처리할지 확인하세요. ${DEFER_PROTOCOL}`);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Stop hook 리마인드 문자열. 없으면 "". 입력은 전체 defer 리스트(번호=전체-인덱스, 인덱스 ref 정합).
|
|
290
|
+
* SessionStart와 동일하게 in_progress를 차등 표면화한다 — "착수했지만 미종결" 항목이 레이더에서
|
|
291
|
+
* 사라지지 않게(resolve가 리마인드에서 항목을 떨구는 harm 완화).
|
|
292
|
+
*/
|
|
293
|
+
export function buildStopReminder(all) {
|
|
294
|
+
const unresolved = all.filter((d) => d.status !== "resolved");
|
|
295
|
+
if (unresolved.length === 0)
|
|
296
|
+
return "";
|
|
297
|
+
return (`🐢 미해결 defer ${unresolved.length}건이 남아 있습니다 (${statusBreakdown(unresolved)}):\n` +
|
|
298
|
+
`${formatDeferList(all)}\n` +
|
|
299
|
+
`${DEFER_PROTOCOL} 다음 세션으로 이월할 거면 의식적으로 확인하세요. (이 리마인드는 1회만 표시됩니다.)`);
|
|
261
300
|
}
|
|
262
301
|
/**
|
|
263
302
|
* SessionStart: 세션 진입 시 미해결 defer를 stdout(plain text)으로 표면화 → Claude 컨텍스트 주입.
|
|
@@ -276,9 +315,16 @@ export async function runSessionStart(ctx) {
|
|
|
276
315
|
const parts = [];
|
|
277
316
|
// 미해결 defer 알림(GBC_NO_SESSION_HINT로 opt-out — 기존 동작 보존).
|
|
278
317
|
if (process.env.GBC_NO_SESSION_HINT !== "1") {
|
|
279
|
-
const hint = buildSessionStartHint(
|
|
280
|
-
if (hint)
|
|
318
|
+
const hint = buildSessionStartHint(loadDefers(cwd));
|
|
319
|
+
if (hint) {
|
|
281
320
|
parts.push(hint);
|
|
321
|
+
// Stop 리마인드 음소거 중이면 진입 시 1회 환기("꺼둔 걸 잊지 않게"). hint가 있을 때만
|
|
322
|
+
// = 미해결 defer가 있을 때만(잔여 0이면 음소거 무관·노이즈). buildSessionStartHint는
|
|
323
|
+
// 순수 유지하고 오케스트레이션에서만 한 줄 첨부(시그니처 미오염).
|
|
324
|
+
if (isStopHintMuted(cwd)) {
|
|
325
|
+
parts.push("🔕 Stop 리마인드 음소거 중 — 매 대화 종료 알림은 꺼져 있습니다 (해제: /gbc-mute).");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
282
328
|
}
|
|
283
329
|
// 업데이트 안내(staleness + version) — SessionStart 보유 코호트(0.2.3+)용. 세션 식별자가 없어
|
|
284
330
|
// 항상 표시되므로 dedup 대신 GBC_NO_UPDATE_NOTICE opt-out에 맡긴다(buildUpdateNotice 내부).
|
package/package.json
CHANGED
package/skills/gate/SKILL.md
CHANGED
|
@@ -12,6 +12,24 @@ description: 거북이코드 구현-전 게이트를 관리한다. 로드된 계
|
|
|
12
12
|
- **미루기는 명시 등록만 허용한다.** "추후작업"이라고 머릿속/주석으로만 미루면 게이트가 침묵 누락으로 차단한다. 정당한 미루기는 반드시 `gbc defer add`로 등록해야 통과된다. (= 통증 "추후작업 미루다 누락" 직격)
|
|
13
13
|
- **게이트는 완전구현을 요구하지 않는다.** 케이스가 다뤄지기 시작했거나 명시 defer되면 통과. 침묵 누락과 시나리오 미지정만 막는다.
|
|
14
14
|
|
|
15
|
+
## defer 수명주기 — 자연어로 전환한다 (사용자가 명령을 직접 칠 필요 없음)
|
|
16
|
+
|
|
17
|
+
defer 항목은 **open(미착수) → in_progress(진행중) → resolved(해결)** 3상태를 갖는다. 대부분의 경우 사용자는 `gbc defer …`를 직접 입력하지 않는다 — **에이전트가 대화(자연어)와 편집 대상을 감지해 백그라운드에서 전환을 실행**하고, 사용자에게 표면화한다. (명령은 수동 보정용으로 항상 사용 가능.)
|
|
18
|
+
|
|
19
|
+
| 전환 | 트리거(감지) | 에이전트 행동 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| **start** (open→진행중) | 그 defer 항목을 **실제로 착수**할 때(NL "이거 할게" 또는 해당 코드 편집 시작) | `gbc defer start <ref>` 자동 실행 + 표면화. 보수적으로 — 실제 착수할 때만(투기적 표시 금지). |
|
|
22
|
+
| **resolve** (→해결) | **사용자의 명시적 완료 선언**("X 끝났어", "점검 OK") | 명확하면 `gbc defer resolve <ref>` 실행 + **반드시 표면화**. |
|
|
23
|
+
| **reopen** (→open) | 사용자가 보류/이월/잘못된 resolve 취소를 요청 | `gbc defer reopen <ref>` 실행 + 표면화. |
|
|
24
|
+
|
|
25
|
+
**resolve 모호성 규칙 (load-bearing — 미완성 항목이 조용히 잊히는 harm 차단):**
|
|
26
|
+
- **명확한 완료 선언**("로그인 검증 끝냈어") → 자동 resolve + 표면화.
|
|
27
|
+
- **모호한 신호**("다음으로 넘어가자", "대충 됐어") → **resolve하지 말고 사용자에게 확인**한다. resolve된 항목은 리마인드/SessionStart에서 사라지므로, 잘못 resolve하면 미완성인 채 잊힌다.
|
|
28
|
+
- resolve는 **절대 게이트(judge)가 편집을 보고 추론하지 않는다** — 항상 사람의 명시 선언이 트리거. (start만 편집 감지로 자동.)
|
|
29
|
+
- 모든 자동 전환은 **사용자에게 표면화**해 catch·reopen할 수 있게 한다.
|
|
30
|
+
|
|
31
|
+
> 이상적 흐름: defer 확인 → (특정/전체 항목) start → 구현 → **사용자 점검** → resolve. 세션 내 완전 해소 안 되는 항목은 in_progress로 이월되고, SessionStart가 "진행중 N · 미착수 M"으로 구분 표면화한다.
|
|
32
|
+
|
|
15
33
|
## 명령 (bash로 실행)
|
|
16
34
|
|
|
17
35
|
게이트는 현재 프로젝트 루트의 `gbc`를 사용한다. 작업 디렉토리에서 실행:
|
|
@@ -19,9 +37,11 @@ description: 거북이코드 구현-전 게이트를 관리한다. 로드된 계
|
|
|
19
37
|
| 의도 | 명령 |
|
|
20
38
|
|---|---|
|
|
21
39
|
| 게이트 상태·로드된 명세 확인 | `gbc status` |
|
|
22
|
-
| 미룬 항목 목록 | `gbc defer list` |
|
|
23
|
-
| 케이스를 명시적으로 미루기 | `gbc defer add "<케이스 설명>"` |
|
|
24
|
-
|
|
|
40
|
+
| 미룬 항목 목록(상태: 미해결/진행중/해결) | `gbc defer list` |
|
|
41
|
+
| 케이스를 명시적으로 미루기 (→ open) | `gbc defer add "<케이스 설명>"` |
|
|
42
|
+
| 착수 표시 (open → 진행중) | `gbc defer start <번호\|텍스트\|all>` |
|
|
43
|
+
| 종결 표시 (→ 해결) | `gbc defer resolve <번호\|텍스트\|all>` |
|
|
44
|
+
| 백로그로 되돌리기 (→ open) | `gbc defer reopen <번호\|텍스트\|all>` |
|
|
25
45
|
| 승인된 시나리오를 명세에 등록 | `gbc spec add "<케이스>"` |
|
|
26
46
|
| 등록된 케이스 목록 | `gbc spec show` |
|
|
27
47
|
| 명세 비우기(작업단위 종료) | `gbc spec clear` |
|
|
@@ -38,14 +58,16 @@ description: 거북이코드 구현-전 게이트를 관리한다. 로드된 계
|
|
|
38
58
|
3. 승인된 케이스를 `gbc spec add "<케이스>"`로 등록하거나 `.gbc/spec.md`에 직접 작성한다.
|
|
39
59
|
4. 재시도하면 통과한다.
|
|
40
60
|
> 시나리오 도출은 코딩 에이전트 본체(Opus)가 대화 맥락으로, 게이트 판정은 haiku가 — 두 작업/두 모델 분리(gbc는 모델 계층을 소유하지 않는다).
|
|
41
|
-
3. **세션 종료 시**: Stop hook이 미해결 defer를 리마인드한다. `gbc defer list`로
|
|
61
|
+
3. **세션 종료 시**: Stop hook이 미해결 defer를 "진행중 N · 미착수 M"으로 구분 리마인드한다. `gbc defer list`로 확인하고, 사용자 완료 선언이 있었으면 resolve, 아니면 다음 세션으로 의식적으로 이월한다(진행중 항목은 in_progress 그대로 남아 다음 SessionStart에 표면화).
|
|
42
62
|
|
|
43
63
|
## 명세 소스
|
|
44
64
|
|
|
45
65
|
게이트는 다음 우선순위로 계획 명세를 읽는다(durable 소스만):
|
|
46
|
-
`$GBC_SPEC_FILE` > `.gbc/spec.md`
|
|
66
|
+
`$GBC_SPEC_FILE` > `.gbc/spec.md`
|
|
67
|
+
|
|
68
|
+
`.gbc/spec.md`가 단일 정본(canonical)이다. `scratch.md` 자동 폴백은 0.2.2에서 제거됐다(진행추적 파일을 명세로 오인하던 거짓음성 차단) — 다른 파일을 명세로 쓰려면 `$GBC_SPEC_FILE`로 명시 지정한다.
|
|
47
69
|
|
|
48
|
-
명세가 비면 "시나리오 미지정"으로 모든 코드 변경이 차단된다.
|
|
70
|
+
명세가 비면 "시나리오 미지정"으로 모든 코드 변경이 차단된다. 보통 위 「사용 흐름」 2의 도출 루프가 `gbc spec add`로 `.gbc/spec.md`를 채운다(수기 작성 불필요).
|
|
49
71
|
|
|
50
72
|
## Known Pitfalls
|
|
51
73
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gbc-mute
|
|
3
|
+
description: 거북이코드(gbc)의 defer Stop 리마인드를 on/off 토글한다. 미해결 defer가 있으면 Stop hook이 매 대화 종료(턴)마다 리마인드를 띄우는데, 이게 거슬릴 때 이 스킬로 음소거하거나 다시 켠다. '/gbc-mute', 'defer 알림 꺼줘', 'defer 알림 그만', '매번 뜨는 거 꺼줘', 'defer 음소거', 'Stop 리마인드 음소거', 'defer 알림 다시 켜줘', '음소거 해제', '리마인드 상태' 등 언급 시 호출.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /gbc-mute — defer Stop 리마인드 음소거 토글
|
|
7
|
+
|
|
8
|
+
미해결 defer가 있으면 거북이코드 **Stop hook이 매 대화 종료(턴)마다** 리마인드를 띄운다. `stop_hook_active` 가드는 한 턴 안의 루프만 끊을 뿐 세션 영속 억제가 아니라, **새 턴마다 재발화**한다(이월해도 계속 노출됨). 이 스킬은 그 매-턴 리마인드를 켜고 끄는 전용 토글이다.
|
|
9
|
+
|
|
10
|
+
## 동작
|
|
11
|
+
|
|
12
|
+
토글은 `.gbc/config.json`의 `stopHintMuted` 플래그로 영속된다(`gbc defer unmute` 전까지 유지 — 새 defer·세션 교체·`gbc gate reset`에도 풀리지 않음).
|
|
13
|
+
|
|
14
|
+
| 사용자 의도 | 명령 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| 현재 상태 확인 | `gbc defer list` (상단에 음소거 여부 표기) |
|
|
17
|
+
| 음소거 켜기 (매-턴 Stop 리마인드 끄기) | `gbc defer mute` |
|
|
18
|
+
| 음소거 끄기 (리마인드 다시 켜기) | `gbc defer unmute` |
|
|
19
|
+
|
|
20
|
+
## 실행 흐름 (에이전트)
|
|
21
|
+
|
|
22
|
+
1. **먼저 현재 상태를 확인**한다 — `gbc defer list`로 음소거 여부를 읽는다.
|
|
23
|
+
2. 사용자 발화에서 의도를 판정해 토글한다:
|
|
24
|
+
- "꺼줘 / 그만 / 음소거 / 조용히" → `gbc defer mute`
|
|
25
|
+
- "켜줘 / 다시 / 해제" → `gbc defer unmute`
|
|
26
|
+
- 의도가 모호하면(예: "/gbc-mute"만 입력) → **현재 상태를 보여주고** 켤지 끌지 사용자에게 묻는다(상태-인지 토글, 무턱대고 뒤집지 않는다).
|
|
27
|
+
3. 실행 결과를 **사용자에게 표면화**한다(`gbc defer mute`/`unmute`의 출력을 그대로 전달).
|
|
28
|
+
|
|
29
|
+
## 끄는 범위 (중요)
|
|
30
|
+
|
|
31
|
+
- **Stop 채널만** 끈다 — 매 대화 종료마다 강요되던 알림.
|
|
32
|
+
- **SessionStart(세션 진입) 알림은 유지**한다. 새 세션 시작 시 "이전 작업 잔여"를 **한 번은** 회상하도록(완전 망각 방지). 즉 음소거는 "매 턴 강요"만 제거하고, 진입 시 1회 환기는 남긴다.
|
|
33
|
+
- 음소거 중이면 SessionStart 진입 알림 끝에 `🔕 음소거 중 (해제: /gbc-mute)` 한 줄이 따라붙고, `gbc status`·`gbc defer list`에도 상태가 표기되므로 "꺼둔 걸 잊는" 일이 없다.
|
|
34
|
+
|
|
35
|
+
## Known Pitfalls
|
|
36
|
+
|
|
37
|
+
- **음소거는 defer를 지우지 않는다.** 항목은 그대로 남아 게이트 판정·SessionStart 회상에 계속 쓰인다. 끄는 건 "매-턴 알림"뿐이다. 항목을 끝낸 거면 음소거가 아니라 `gbc defer resolve`다(→ `/gate`).
|
|
38
|
+
- **SessionStart는 음소거 대상이 아니다.** "진입 시에도 안 뜨게" 해달라는 요청이면 음소거로는 안 된다 — 그건 별개 채널(`GBC_NO_SESSION_HINT=1`)이며 의도적으로 분리돼 있다.
|