geobuke-code 0.1.0 → 0.2.0
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 +31 -6
- package/dist/cli.js +58 -1
- package/dist/hook.js +52 -1
- package/dist/metrics.js +120 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -21,7 +21,17 @@
|
|
|
21
21
|
|
|
22
22
|
## 설치
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
```bash
|
|
25
|
+
# 1) 전역 설치
|
|
26
|
+
npm install -g geobuke-code
|
|
27
|
+
|
|
28
|
+
# 2) 대상 프로젝트에 게이트 설치
|
|
29
|
+
cd <your-project>
|
|
30
|
+
gbc init # .claude/settings.json에 hook + /gate skill 머지 (동의·백업)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
<details>
|
|
34
|
+
<summary>로컬 개발 설치 (소스에서 빌드)</summary>
|
|
25
35
|
|
|
26
36
|
```bash
|
|
27
37
|
# 1) 클론 + 빌드 (dist/ 생성)
|
|
@@ -36,13 +46,15 @@ printf '#!/bin/sh\nexec node "%s/dist/cli.js" "$@"\n' "$PWD" > ~/.local/bin/gbc
|
|
|
36
46
|
|
|
37
47
|
# 3) 대상 프로젝트에 게이트 설치
|
|
38
48
|
cd <your-project>
|
|
39
|
-
gbc init
|
|
49
|
+
gbc init
|
|
40
50
|
```
|
|
41
51
|
|
|
42
|
-
`gbc init`은 **프로젝트 로컬 `.claude/settings.json`만** 머지한다(append·멱등·백업). 전역 `~/.claude`는 건드리지 않는다.
|
|
43
|
-
|
|
44
52
|
소스를 수정하면 `npm run build`만 다시 하면 된다 — wrapper/link는 같은 `dist/cli.js`를 가리키므로 재연결 불필요.
|
|
45
53
|
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
`gbc init`은 **프로젝트 로컬 `.claude/settings.json`만** 머지한다(append·멱등·백업). 전역 `~/.claude`는 건드리지 않는다. `~/.gbc/api-key`가 있으면 hook 명령에 키 주입까지 자동화한다(아래 [빠른 게이트 활성화](#빠른-게이트-활성화-api-키--선택)).
|
|
57
|
+
|
|
46
58
|
## 빠른 게이트 활성화 (API 키 — 선택)
|
|
47
59
|
|
|
48
60
|
`gbc init`은 키 주입 **없는** hook을 설치한다 → 기본은 `claude -p` 폴백(~13–20s). **haiku 직접 API(~1–3s)**를 쓰려면 키를 **hook 명령에만** 주입한다.
|
|
@@ -111,16 +123,29 @@ phase-protocol/계획 → /plan(SubTask) → 【게이트: 구현 직전 케이
|
|
|
111
123
|
| `gbc spec show` | 등록된 케이스 목록 |
|
|
112
124
|
| `gbc spec clear` | 명세 비우기(작업단위 종료) |
|
|
113
125
|
| `gbc gate reset` | 작업단위 게이트 리셋 |
|
|
126
|
+
| `gbc metrics [--json]` | 계측 리포트(M1~M3) |
|
|
114
127
|
|
|
115
128
|
우회: `GBC_NO_GATE=1` (계측됨 — 우회 자체가 게이트 가치 측정 데이터).
|
|
116
129
|
|
|
130
|
+
## 계측 (M1~M3)
|
|
131
|
+
|
|
132
|
+
게이트는 모든 결정을 `.gbc/events.jsonl`(append-only, 메타데이터만 — 코드 본문 미기록)에 기록한다. `gbc metrics`로 집계를 본다. 끄려면 `GBC_NO_METRICS=1`.
|
|
133
|
+
|
|
134
|
+
| 지표 | 관측 | B-모드 신뢰도 |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| **M2** 게이트 적중 vs 도중발견 | 차단이 잡은 누락 케이스 수 vs `defer add`로 도중 등록된 수 | **강** (defer-registry와 1:1) |
|
|
137
|
+
| **M3** 재호출/iteration | 작업단위당 편집 반복 횟수 | proxy |
|
|
138
|
+
| **M1** post-gate 재작업 | 통과 후 churn(spec 변경·gate reset·defer) | **약** (churn proxy) |
|
|
139
|
+
|
|
140
|
+
> ⚠️ **진짜 M1**(통과 후 시나리오 위반율)은 게이트가 엔진 출력을 채점하는 **사후 대조**가 필요하다 — 이는 후속 A(standalone) 모드 영역이다. B-커널(hook)은 churn 약신호만 관측한다. `events.jsonl` 원시 로그는 그때 그대로 재사용된다.
|
|
141
|
+
|
|
117
142
|
## 정직한 한계
|
|
118
143
|
|
|
119
144
|
- 사후 대조가 아닌 **구현 전 게이트**다 — "도중 탈선"은 못 잡는다(설계상 후속 C 영역).
|
|
120
145
|
- 판정은 LLM이라 100% 아니다. **사람이 변이 전 케이스를 리뷰/편집하는 pause**가 진짜 가치다.
|
|
121
|
-
- MVP scope = **B-커널**(CC-native hook + defer-registry + /gate). standalone TUI·추출
|
|
146
|
+
- MVP scope = **B-커널**(CC-native hook + defer-registry + /gate). standalone TUI·추출 모드는 후속 A(public). 계측은 B-모드 관측 프록시(M1~M3)까지 구현됨(위 [계측](#계측-m1m3)).
|
|
122
147
|
- **검증 상태**: 게이트 판정 품질은 **양 트랜스포트 모두 회귀 8/8(FP0 FN0)**. 직접 API(haiku) 경로 실측 **평균 1.7s**(1.1–2.5s), claude -p 폴백 ~18s. 직접 API용 게이트 프롬프트는 최소화하면서 정확도를 유지하도록 "동작 편집 vs 비-동작 편집" 2단계 분류로 튜닝했다(`ANTHROPIC_API_KEY=… node dist/eval/regression.js`로 재현).
|
|
123
|
-
- **fail-open**: 판정 호출이 실패하면(키 오류·네트워크 등) 게이트는
|
|
148
|
+
- **fail-open**: 판정 호출이 실패하면(키 오류·네트워크 등) 게이트는 안전하게 통과시킨다(개발 차단 방지). 단 fail-open 통과는 작업단위 캐시에서 제외되고(다음 편집 재판정), `systemMessage` 경고 + `.gbc/failopen.log` 계측으로 드러난다(조용한 무력화 방지).
|
|
124
149
|
|
|
125
150
|
## 라이선스
|
|
126
151
|
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ 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, upgradeKeylessHooks } from "./install.js";
|
|
13
|
+
import { logEvent, parseEvents, computeMetrics } from "./metrics.js";
|
|
13
14
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
14
15
|
const PKG_ROOT = join(dirname(CLI_PATH), ".."); // dist/cli.js → 패키지 루트
|
|
15
16
|
/** ~/.gbc/api-key 존재 여부 — 있으면 hook에 키 주입(빠른 haiku 경로). */
|
|
@@ -19,6 +20,26 @@ function hasApiKey() {
|
|
|
19
20
|
function stopCommand() {
|
|
20
21
|
return `node "${CLI_PATH}" hook stop`;
|
|
21
22
|
}
|
|
23
|
+
function nowIso() {
|
|
24
|
+
try {
|
|
25
|
+
return new Date().toISOString();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 현재 작업단위 명세 해시 (CLI 이벤트의 specHash 상관 키).
|
|
33
|
+
* 빈 spec은 ""(센티넬) — M1 churn 교차세션 합산 방지(computeMetrics가 제외).
|
|
34
|
+
*/
|
|
35
|
+
function curHash(cwd) {
|
|
36
|
+
const text = loadPlanSpec(cwd).text;
|
|
37
|
+
return text.trim() === "" ? "" : computeSpecHash(text);
|
|
38
|
+
}
|
|
39
|
+
/** CLI 변이 이벤트를 events.jsonl에 기록(메트릭 상관용). specHash는 변이 전 값을 넘긴다. */
|
|
40
|
+
function logCli(cwd, kind, specHash) {
|
|
41
|
+
logEvent(cwd, { at: nowIso(), session: "", specHash, kind });
|
|
42
|
+
}
|
|
22
43
|
function nowStamp() {
|
|
23
44
|
try {
|
|
24
45
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
@@ -133,6 +154,7 @@ function cmdDefer(args) {
|
|
|
133
154
|
process.exit(1);
|
|
134
155
|
}
|
|
135
156
|
addDefer(cwd, item);
|
|
157
|
+
logCli(cwd, "defer-add", curHash(cwd));
|
|
136
158
|
console.log(`🐢 미룸 등록: ${item}`);
|
|
137
159
|
}
|
|
138
160
|
else if (sub === "list") {
|
|
@@ -146,6 +168,8 @@ function cmdDefer(args) {
|
|
|
146
168
|
else if (sub === "resolve") {
|
|
147
169
|
const ref = args.slice(1).join(" ").trim();
|
|
148
170
|
const r = resolveDefer(cwd, ref);
|
|
171
|
+
if (r)
|
|
172
|
+
logCli(cwd, "defer-resolve", curHash(cwd));
|
|
149
173
|
console.log(r ? `🐢 해결 표시: ${r.item}` : `매칭되는 미룬 항목 없음: ${ref}`);
|
|
150
174
|
}
|
|
151
175
|
else {
|
|
@@ -163,7 +187,9 @@ function cmdSpec(args) {
|
|
|
163
187
|
console.error('사용: gbc spec add "<케이스/시나리오>"');
|
|
164
188
|
process.exit(1);
|
|
165
189
|
}
|
|
190
|
+
const beforeHash = curHash(cwd); // 변이 전 해시 = 수정 대상 작업단위와 상관
|
|
166
191
|
addSpecCase(cwd, item);
|
|
192
|
+
logCli(cwd, "spec-add", beforeHash);
|
|
167
193
|
console.log(`🐢 명세 등록: ${item}`);
|
|
168
194
|
}
|
|
169
195
|
else if (sub === "show") {
|
|
@@ -175,7 +201,9 @@ function cmdSpec(args) {
|
|
|
175
201
|
cases.forEach((c, i) => console.log(`${i + 1}. ${c}`));
|
|
176
202
|
}
|
|
177
203
|
else if (sub === "clear") {
|
|
204
|
+
const beforeHash = curHash(cwd);
|
|
178
205
|
clearSpec(cwd);
|
|
206
|
+
logCli(cwd, "spec-clear", beforeHash);
|
|
179
207
|
console.log("🐢 명세 비움 — 다음 작업단위로 깨끗이 넘어갑니다.");
|
|
180
208
|
}
|
|
181
209
|
else {
|
|
@@ -186,7 +214,9 @@ function cmdSpec(args) {
|
|
|
186
214
|
// ---------- gbc gate ----------
|
|
187
215
|
function cmdGate(args) {
|
|
188
216
|
if (args[0] === "reset") {
|
|
189
|
-
|
|
217
|
+
const cwd = process.cwd();
|
|
218
|
+
logCli(cwd, "gate-reset", curHash(cwd));
|
|
219
|
+
resetGate(cwd);
|
|
190
220
|
console.log("🐢 작업단위 게이트 리셋 — 다음 편집에서 다시 발동합니다.");
|
|
191
221
|
}
|
|
192
222
|
else {
|
|
@@ -194,6 +224,30 @@ function cmdGate(args) {
|
|
|
194
224
|
process.exit(1);
|
|
195
225
|
}
|
|
196
226
|
}
|
|
227
|
+
// ---------- gbc metrics ----------
|
|
228
|
+
function cmdMetrics(args) {
|
|
229
|
+
const cwd = process.cwd();
|
|
230
|
+
const eventsPath = join(cwd, ".gbc", "events.jsonl");
|
|
231
|
+
const raw = existsSync(eventsPath) ? readFileSync(eventsPath, "utf8") : "";
|
|
232
|
+
const m = computeMetrics(parseEvents(raw));
|
|
233
|
+
if (args.includes("--json")) {
|
|
234
|
+
console.log(JSON.stringify(m, null, 2));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
console.log(`🐢 거북이 게이트 계측 — ${cwd}
|
|
238
|
+
이벤트 총 ${m.totalEvents}건 (.gbc/events.jsonl)
|
|
239
|
+
|
|
240
|
+
[M3] 재호출/iteration — 작업단위당 edit 반복
|
|
241
|
+
작업단위 ${m.m3.workUnits} · 총 edit ${m.m3.totalEdits} · 평균 ${m.m3.avgEditsPerUnit}/단위 · 최대 ${m.m3.maxEditsPerUnit} · 반복(>1)단위 ${m.m3.multiEditUnits}
|
|
242
|
+
|
|
243
|
+
[M2] 게이트 적중 vs 도중발견
|
|
244
|
+
게이트 적중(차단 누락케이스) ${m.m2.gateCaught} · 차단 ${m.m2.blocks}회
|
|
245
|
+
도중발견(defer 등록) ${m.m2.deferred} · 도중발견 비율 ${(m.m2.midDiscoveryRatio * 100).toFixed(1)}%
|
|
246
|
+
|
|
247
|
+
[M1] post-gate 재작업
|
|
248
|
+
게이트 리셋 ${m.m1.resets} · 통과후 churn ${m.m1.churnAfterPass}
|
|
249
|
+
⚠️ ${m.m1.note}`);
|
|
250
|
+
}
|
|
197
251
|
function usage() {
|
|
198
252
|
console.log(`🐢 gbc — 거북이코드 구현-전 게이트
|
|
199
253
|
|
|
@@ -207,6 +261,7 @@ function usage() {
|
|
|
207
261
|
gbc spec show 등록된 케이스 목록
|
|
208
262
|
gbc spec clear 명세 비우기(작업단위 종료)
|
|
209
263
|
gbc gate reset 작업단위 게이트 리셋
|
|
264
|
+
gbc metrics [--json] 계측 리포트(M1~M3, B-모드 관측 프록시)
|
|
210
265
|
gbc hook pre-tool-use (내부) PreToolUse hook
|
|
211
266
|
gbc hook stop (내부) Stop hook
|
|
212
267
|
`);
|
|
@@ -232,6 +287,8 @@ async function main() {
|
|
|
232
287
|
return cmdSpec(rest);
|
|
233
288
|
case "gate":
|
|
234
289
|
return cmdGate(rest);
|
|
290
|
+
case "metrics":
|
|
291
|
+
return cmdMetrics(rest);
|
|
235
292
|
case undefined:
|
|
236
293
|
case "help":
|
|
237
294
|
case "--help":
|
package/dist/hook.js
CHANGED
|
@@ -8,6 +8,7 @@ import { activeDeferItems, unresolvedDefers, loadDefers } from "./defer.js";
|
|
|
8
8
|
import { appendFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { gbcDir } from "./store.js";
|
|
11
|
+
import { logEvent } from "./metrics.js";
|
|
11
12
|
/**
|
|
12
13
|
* 차단 사유 메시지를 빌드한다. 두 차단 종류를 다르게 안내한다:
|
|
13
14
|
* - specEmpty=true (시나리오 미지정): 에이전트가 요청에서 시나리오를 도출 → 사용자 검증 →
|
|
@@ -48,6 +49,14 @@ function readStdin() {
|
|
|
48
49
|
function emit(obj) {
|
|
49
50
|
process.stdout.write(JSON.stringify(obj));
|
|
50
51
|
}
|
|
52
|
+
function nowIso() {
|
|
53
|
+
try {
|
|
54
|
+
return new Date().toISOString();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
51
60
|
function logBypass(cwd, toolName) {
|
|
52
61
|
try {
|
|
53
62
|
appendFileSync(join(gbcDir(cwd), "bypass.log"), `${new Date().toISOString()} ${toolName}\n`);
|
|
@@ -78,19 +87,34 @@ export async function runPreToolUse() {
|
|
|
78
87
|
}
|
|
79
88
|
const toolName = input.tool_name ?? "";
|
|
80
89
|
const cwd = input.cwd || process.cwd();
|
|
90
|
+
const session = input.session_id ?? "";
|
|
81
91
|
// 코드 변경 도구가 아니면 즉시 통과
|
|
82
92
|
if (!isGatedTool(toolName))
|
|
83
93
|
process.exit(0);
|
|
84
94
|
// 명시적 우회 (계측됨)
|
|
85
95
|
if (process.env.GBC_NO_GATE === "1") {
|
|
86
96
|
logBypass(cwd, toolName);
|
|
97
|
+
logEvent(cwd, { at: nowIso(), session, specHash: "", kind: "bypass", tool: toolName });
|
|
87
98
|
process.exit(0);
|
|
88
99
|
}
|
|
89
100
|
const { text: specText, source } = loadPlanSpec(cwd);
|
|
90
101
|
const specHash = computeSpecHash(specText);
|
|
102
|
+
// 계측용 해시: 빈 spec은 ""(센티넬)로 기록 → M1 churn 교차세션 합산 방지.
|
|
103
|
+
// (게이트 캐시용 specHash는 그대로 — markGated/isGated 동작 불변)
|
|
104
|
+
const logHash = specText.trim() === "" ? "" : specHash;
|
|
91
105
|
// 작업단위 1회: 이미 게이트 통과한 단위면 즉시 통과 (judge 미호출, 핫패스)
|
|
92
|
-
|
|
106
|
+
// 계측: cached-skip도 기록해야 M3(작업단위당 edit 반복)이 진짜 횟수를 잡는다.
|
|
107
|
+
if (isGated(cwd, specHash)) {
|
|
108
|
+
logEvent(cwd, {
|
|
109
|
+
at: nowIso(),
|
|
110
|
+
session,
|
|
111
|
+
specHash: logHash,
|
|
112
|
+
kind: "gate",
|
|
113
|
+
tool: toolName,
|
|
114
|
+
decision: "cached",
|
|
115
|
+
});
|
|
93
116
|
process.exit(0);
|
|
117
|
+
}
|
|
94
118
|
// judge는 여기서만 동적 import (SDK lazy)
|
|
95
119
|
const { judge } = await import("./judge.js");
|
|
96
120
|
const editText = normalizeEdit(toolName, input.tool_input ?? {});
|
|
@@ -99,11 +123,28 @@ export async function runPreToolUse() {
|
|
|
99
123
|
if (verdict.verdict === "pass") {
|
|
100
124
|
if (shouldCacheVerdict(verdict)) {
|
|
101
125
|
markGated(cwd, specHash, verdict.reason);
|
|
126
|
+
logEvent(cwd, {
|
|
127
|
+
at: nowIso(),
|
|
128
|
+
session,
|
|
129
|
+
specHash: logHash,
|
|
130
|
+
kind: "gate",
|
|
131
|
+
tool: toolName,
|
|
132
|
+
decision: "pass",
|
|
133
|
+
deferCount: defers.length,
|
|
134
|
+
});
|
|
102
135
|
process.exit(0); // 정상 통과 (자동승인 X — 무출력)
|
|
103
136
|
}
|
|
104
137
|
// fail-open: 판정 실패로 안전 통과. 캐시하지 않아(작업단위 무력화 방지) 다음 편집에서 재판정.
|
|
105
138
|
// 계측 + systemMessage로 사용자에게 "게이트가 검사 못 했음"을 알린다(조용한 무력화 방지).
|
|
106
139
|
logFailOpen(cwd, toolName, verdict.reason);
|
|
140
|
+
logEvent(cwd, {
|
|
141
|
+
at: nowIso(),
|
|
142
|
+
session,
|
|
143
|
+
specHash: logHash,
|
|
144
|
+
kind: "gate",
|
|
145
|
+
tool: toolName,
|
|
146
|
+
decision: "failopen",
|
|
147
|
+
});
|
|
107
148
|
emit({
|
|
108
149
|
systemMessage: `🐢 거북이 게이트 — 판정 실패로 안전 통과(fail-open). 이 편집은 게이트 검사를 받지 못했습니다: ${verdict.reason}`,
|
|
109
150
|
hookSpecificOutput: {
|
|
@@ -117,6 +158,16 @@ export async function runPreToolUse() {
|
|
|
117
158
|
// block: 사람 pause (ask 기본) — 사유가 사용자에게 표시됨
|
|
118
159
|
// 시나리오 미지정(명세 빈약)과 침묵 누락을 다르게 안내한다.
|
|
119
160
|
const reason = buildBlockReason(verdict, specText.trim() === "", source);
|
|
161
|
+
logEvent(cwd, {
|
|
162
|
+
at: nowIso(),
|
|
163
|
+
session,
|
|
164
|
+
specHash: logHash,
|
|
165
|
+
kind: "gate",
|
|
166
|
+
tool: toolName,
|
|
167
|
+
decision: "block",
|
|
168
|
+
missing: verdict.missing,
|
|
169
|
+
deferCount: defers.length,
|
|
170
|
+
});
|
|
120
171
|
const mode = process.env.GBC_BLOCK_MODE === "deny" ? "deny" : "ask";
|
|
121
172
|
emit({
|
|
122
173
|
hookSpecificOutput: {
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// 거북이코드 계측 레이어 (M1~M3) — B-모드 hook 관측 프록시.
|
|
2
|
+
// 1차 자산 = 원시 events.jsonl(append-only). 메트릭은 그 위의 thin 집계.
|
|
3
|
+
// ⚠️ 진짜 M1(post-gate 시나리오위반율)은 A-mode 사후대조 필요 — B-모드는 churn 약신호만.
|
|
4
|
+
import { appendFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { gbcDir } from "./store.js";
|
|
7
|
+
/** 한 줄 이벤트의 최대 바이트 — O_APPEND atomic 보장(미만 길이) */
|
|
8
|
+
const MAX_LINE = 4096;
|
|
9
|
+
/** missing[] 캡 (항목 수 / 항목당 길이) */
|
|
10
|
+
const MAX_MISSING_ITEMS = 20;
|
|
11
|
+
const MAX_MISSING_LEN = 200;
|
|
12
|
+
/** missing[]을 항목 수/길이로 캡 */
|
|
13
|
+
function capMissing(missing) {
|
|
14
|
+
return missing
|
|
15
|
+
.slice(0, MAX_MISSING_ITEMS)
|
|
16
|
+
.map((m) => (m.length > MAX_MISSING_LEN ? m.slice(0, MAX_MISSING_LEN) : m));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 이벤트를 한 줄 JSON으로 직렬화. missing[]을 캡하고, 그래도 MAX_LINE을 넘으면
|
|
20
|
+
* missing을 요약 토큰으로 대체해 라인 길이를 보장한다(O_APPEND atomic).
|
|
21
|
+
*/
|
|
22
|
+
export function serializeEvent(e) {
|
|
23
|
+
const out = { ...e };
|
|
24
|
+
if (out.missing)
|
|
25
|
+
out.missing = capMissing(out.missing);
|
|
26
|
+
let line = JSON.stringify(out);
|
|
27
|
+
if (line.length >= MAX_LINE && out.missing) {
|
|
28
|
+
out.missing = [`${e.missing?.length ?? 0} items (truncated)`];
|
|
29
|
+
line = JSON.stringify(out);
|
|
30
|
+
}
|
|
31
|
+
// 극단적 경우(다른 필드가 비대)에도 캡 — 한 줄 보장
|
|
32
|
+
if (line.length >= MAX_LINE)
|
|
33
|
+
line = line.slice(0, MAX_LINE - 1);
|
|
34
|
+
return line;
|
|
35
|
+
}
|
|
36
|
+
/** jsonl 원시 텍스트를 이벤트 배열로 파싱 (빈 줄·깨진 줄 skip) */
|
|
37
|
+
export function parseEvents(raw) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const line of raw.split("\n")) {
|
|
40
|
+
const t = line.trim();
|
|
41
|
+
if (!t)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const obj = JSON.parse(t);
|
|
45
|
+
if (obj && typeof obj === "object" && typeof obj.kind === "string") {
|
|
46
|
+
out.push(obj);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* 깨진 줄 skip */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const M1_NOTE = "B-모드 약신호(churn proxy) — 진짜 M1(post-gate 시나리오 위반율)은 A-mode 사후대조 필요. " +
|
|
56
|
+
"spec.md 비었을 때(specHash='')는 작업단위 식별 불가라 churn 집계에서 제외(교차세션 합산 방지).";
|
|
57
|
+
/** 그룹핑 키: session 우선, 없으면 specHash(CLI 이벤트 상관) */
|
|
58
|
+
function groupKey(e) {
|
|
59
|
+
return e.session || e.specHash;
|
|
60
|
+
}
|
|
61
|
+
function round3(n) {
|
|
62
|
+
return Math.round(n * 1000) / 1000;
|
|
63
|
+
}
|
|
64
|
+
/** 이벤트 배열 → M1/M2/M3 집계. 순수함수(파일 I/O 없음). */
|
|
65
|
+
export function computeMetrics(events) {
|
|
66
|
+
const gate = events.filter((e) => e.kind === "gate");
|
|
67
|
+
// M3 — 작업단위(session||specHash)별 gate 이벤트 수 = edit 반복 proxy
|
|
68
|
+
const perUnit = new Map();
|
|
69
|
+
for (const e of gate)
|
|
70
|
+
perUnit.set(groupKey(e), (perUnit.get(groupKey(e)) ?? 0) + 1);
|
|
71
|
+
const counts = [...perUnit.values()];
|
|
72
|
+
const workUnits = counts.length;
|
|
73
|
+
const totalEdits = gate.length;
|
|
74
|
+
const maxEditsPerUnit = counts.length ? Math.max(...counts) : 0;
|
|
75
|
+
const multiEditUnits = counts.filter((c) => c > 1).length;
|
|
76
|
+
const avgEditsPerUnit = workUnits ? round3(totalEdits / workUnits) : 0;
|
|
77
|
+
// M2 — 게이트적중(Σ block.missing) vs 도중발견(defer-add)
|
|
78
|
+
const blockEvents = gate.filter((e) => e.decision === "block");
|
|
79
|
+
const gateCaught = blockEvents.reduce((s, e) => s + (e.missing?.length ?? 0), 0);
|
|
80
|
+
const deferred = events.filter((e) => e.kind === "defer-add").length;
|
|
81
|
+
const denom = gateCaught + deferred;
|
|
82
|
+
const midDiscoveryRatio = denom ? round3(deferred / denom) : 0;
|
|
83
|
+
// M1 — specHash별 first pass 이후의 churn(spec-add/clear/gate-reset/defer-add).
|
|
84
|
+
// ⚠️ 빈 specHash("")는 spec.md 없는 작업단위라 식별 불가 → 교차세션 합산을 막기 위해 제외.
|
|
85
|
+
const firstPassAt = new Map();
|
|
86
|
+
for (const e of gate) {
|
|
87
|
+
if (e.decision !== "pass" || !e.specHash)
|
|
88
|
+
continue;
|
|
89
|
+
const cur = firstPassAt.get(e.specHash);
|
|
90
|
+
if (cur === undefined || e.at < cur)
|
|
91
|
+
firstPassAt.set(e.specHash, e.at);
|
|
92
|
+
}
|
|
93
|
+
const CHURN_KINDS = ["spec-add", "spec-clear", "gate-reset", "defer-add"];
|
|
94
|
+
let churnAfterPass = 0;
|
|
95
|
+
for (const e of events) {
|
|
96
|
+
if (!CHURN_KINDS.includes(e.kind) || !e.specHash)
|
|
97
|
+
continue;
|
|
98
|
+
const passAt = firstPassAt.get(e.specHash);
|
|
99
|
+
if (passAt !== undefined && e.at > passAt)
|
|
100
|
+
churnAfterPass++;
|
|
101
|
+
}
|
|
102
|
+
const resets = events.filter((e) => e.kind === "gate-reset").length;
|
|
103
|
+
return {
|
|
104
|
+
totalEvents: events.length,
|
|
105
|
+
m3: { workUnits, totalEdits, avgEditsPerUnit, maxEditsPerUnit, multiEditUnits },
|
|
106
|
+
m2: { gateCaught, blocks: blockEvents.length, deferred, midDiscoveryRatio },
|
|
107
|
+
m1: { resets, churnAfterPass, note: M1_NOTE },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** events.jsonl에 이벤트 1줄 append. 실패는 무시(계측이 개발 흐름을 막지 않음). */
|
|
111
|
+
export function logEvent(cwd, event) {
|
|
112
|
+
if (process.env.GBC_NO_METRICS === "1")
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
appendFileSync(join(gbcDir(cwd), "events.jsonl"), serializeEvent(event) + "\n");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* 계측 실패는 무시 */
|
|
119
|
+
}
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "geobuke-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "거북이코드 — 구현 직전 강제 게이트. Claude Code PreToolUse hook으로 코드 변경 전 계획 케이스 누락·시나리오 미지정을 차단한다.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"test": "node --test 'test/**/*.test.mjs'",
|
|
19
19
|
"eval": "node dist/eval/regression.js",
|
|
20
20
|
"prepare": "tsc",
|
|
21
|
-
"prepublishOnly": "npm run build && npm test"
|
|
21
|
+
"prepublishOnly": "npm run build && npm test",
|
|
22
|
+
"release": "sh scripts/publish.sh"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
24
25
|
"claude-code",
|