leerness 1.36.85 → 1.36.88

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/lib/bugfix.js ADDED
@@ -0,0 +1,335 @@
1
+ // lib/bugfix.js — bugfix 완료 영수증 (1.36.87, P-0005 · codex+자체 이중검토 통과분만 구현).
2
+ //
3
+ // 문제(사용자 제기 + 실사용 프로젝트 실측): 버그 수정을 요청하면 증상만 고치고 "고쳤다"고 판정한다.
4
+ // 읽기 전용으로 스캔한 실사용 저장소에서: 주문 상태 오판정이 플랫폼을 갈아가며 8회 반복,
5
+ // 한 파일이 22회 수정, 그 프로젝트 lessons 11건은 전부 개별 API 특이사항이고 **클래스 교훈은 0건**.
6
+ //
7
+ // 검토에서 **기각된 것**(만들지 않는다):
8
+ // · 재오픈 ID 경고 — 실측 정밀도 19%(후속 커밋 37건 중 문서 14·확장 4·되돌림 1). 80% 오탐이라 차단 불가.
9
+ // · 파일 hotspot 경고 — 원래 자주 바뀌는 파일(라우팅·registry·lock)과 구분 불가. dirty worktree 에서
10
+ // "이번 버그가 손댈 파일"을 추론하면 즉시 오염된다.
11
+ // · 형제 분기 자동 제시 — **구문 분기 ≠ 도메인 형제**. 같은 파일 안에서도 플랫폼마다 수집 단위·조기반환이
12
+ // 다르고, 공통 규칙은 분기 뒤에 다시 적용된다. 어떤 invariant 를 고치느냐에 따라 형제 범위가 달라지므로
13
+ // 기계가 발명할 수 없다. 0-deps 로 TS 의미구조를 얻으려면 자체 파서가 필요하고 정규식은 과다/과소 포함된다.
14
+ //
15
+ // 채택한 유일한 개입: **`--status done` 전이**. leerness 는 이미 debug 렌즈와 review-request 로 올바른 절차를
16
+ // 안내하지만 그건 조언 텍스트일 뿐 완료 시점에 소비되지 않는다. 조언을 더 만드는 것이야말로 증상 처방이다.
17
+ //
18
+ // 정직성 계약: 여기서 **기계적으로 검증되는 것은 probe 뿐**이다(수정 전 2회 연속 실패 → 수정 후 통과).
19
+ // 근본원인·형제범위는 **선언**이며 leerness 는 그 내용의 진위를 판정하지 않는다. 출력에 그대로 표시한다.
20
+ //
21
+ // 신뢰 경계(명시): 스토어(.harness/bugfix-receipts.json)는 **프로젝트가 신뢰하는 설정 파일**이고,
22
+ // 등록된 probe 명령은 done 전이에서 셸로 실행된다. 저장소에 쓰기 권한이 있는 주체는 이 게이트를
23
+ // 우회할 수 있다 — 이 기능은 "성실한 작업자의 성급한 완료"를 막지, 적대적 우회를 막지 않는다.
24
+ // 그래서 등록 시 실행 사실을 고지하고(⚠), 형상 무효 스토어는 완료를 보류한다.
25
+ 'use strict';
26
+ const cp = require('child_process');
27
+ const path = require('path');
28
+ const { absRoot, exists, read, writeUtf8, mkdirp, log, ok, warn, failJson, now } = require('./io');
29
+
30
+ function _storePath(root) { return path.join(absRoot(root), '.harness', 'bugfix-receipts.json'); }
31
+
32
+ const _ID_RE = /^[A-Z]+-\d{3,}$/;
33
+
34
+ // 1.36.87 (codex 26차 #5/#7): baseline 을 **필수**로 만든다. 종전엔 baseline 없는/실패하지 않은 엔트리가
35
+ // 유효로 통과해, (1) 완료 렌더에서 TypeError 로 `task update` 가 통째로 죽고(실측),
36
+ // (2) 손으로 써 넣은 "재현된 적 없는" probe 가 게이트를 통과했다.
37
+ // 등록 경로(_start)는 실패한 baseline 만 저장하므로, 그 형상을 스토어 계약으로 못 박는다.
38
+ function _entryValid(e) {
39
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return false;
40
+ if (typeof e.id !== 'string' || !_ID_RE.test(e.id)) return false;
41
+ if (typeof e.repro !== 'string' || !e.repro.trim()) return false;
42
+ if (typeof e.expectBad !== 'string' || !e.expectBad.trim()) return false;
43
+ const b = e.baseline;
44
+ if (!b || typeof b !== 'object' || Array.isArray(b)) return false;
45
+ if (b.failed !== true) return false; // 재현되지 않은 probe 는 등록될 수 없다
46
+ if (typeof b.exit !== 'number') return false;
47
+ if (e.siblingScope != null && (typeof e.siblingScope !== 'object' || Array.isArray(e.siblingScope))) return false;
48
+ if (e.siblingScope && e.siblingScope.checked != null && !Array.isArray(e.siblingScope.checked)) return false;
49
+ if (e.rootCause != null && typeof e.rootCause !== 'string') return false;
50
+ return true;
51
+ }
52
+
53
+ // 형제범위가 "기록됨"인지의 **단일 술어** — 목록·게이트가 갈라지면 사용자가 통과할 줄 알았다가 막힌다.
54
+ // 빈 배열은 기록이 아니다(`--siblings ","` 가 checked:[] 를 만들던 공허 영수증).
55
+ function _siblingScopeOk(sc) {
56
+ if (!sc || typeof sc !== 'object') return false;
57
+ if (Array.isArray(sc.checked) && sc.checked.length > 0) return true;
58
+ return typeof sc.notApplicable === 'string' && !!sc.notApplicable.trim();
59
+ }
60
+
61
+ // 손상/형상무효 스토어는 빈 목록으로 오인하지 않는다(referee/previews 와 동일 규율).
62
+ function _loadChecked(root) {
63
+ const f = _storePath(root);
64
+ if (!exists(f)) return { list: [], invalid: false };
65
+ try {
66
+ const j = JSON.parse(read(f));
67
+ if (!Array.isArray(j) || j.some(e => !_entryValid(e))) return { list: [], invalid: true };
68
+ return { list: j, invalid: false };
69
+ } catch { return { list: [], invalid: true }; }
70
+ }
71
+ // 1.36.87 (codex 26차 #6): 종전엔 읽은 목록 전체를 그대로 덮어써서, 두 등록이 겹치면 나중 쓰기가
72
+ // 앞의 등록을 지웠다 — 지워진 task 는 "미등록"이 되어 게이트가 **조용히 꺼진다**(fail-open).
73
+ // 쓰기 직전 재로드 후 id 기준 병합하고, 임시파일 rename 으로 부분 기록도 막는다.
74
+ // 락은 호출부에서 주입한다(rules/decisions/teams/referee 와 동일 규율) — 재로드+병합만으로는 부족했다:
75
+ // 4개 동시 등록 실측에서 2건이 사라졌다.
76
+ function _atomicWrite(f, list) {
77
+ mkdirp(path.dirname(f));
78
+ const tmp = f + '.tmp' + process.pid;
79
+ writeUtf8(tmp, JSON.stringify(list, null, 2) + '\n');
80
+ require('fs').renameSync(tmp, f);
81
+ }
82
+ function _locked(deps, f, fn) { return (deps && typeof deps._withLock === 'function') ? deps._withLock(f, fn) : fn(); }
83
+ function _upsert(root, entry, deps) {
84
+ const f = _storePath(root);
85
+ return _locked(deps, f, () => {
86
+ const cur = _loadChecked(root); // 락 안에서 재로드 — 밖에서 읽은 목록은 이미 낡았을 수 있다
87
+ const list = (cur.invalid ? [] : cur.list).filter(x => x.id !== entry.id).concat([entry]);
88
+ _atomicWrite(f, list);
89
+ return list;
90
+ });
91
+ }
92
+ function _remove(root, id, deps) {
93
+ const f = _storePath(root);
94
+ return _locked(deps, f, () => {
95
+ const cur = _loadChecked(root);
96
+ if (cur.invalid) return null;
97
+ const list = cur.list.filter(x => x.id !== String(id));
98
+ if (list.length === cur.list.length) return null;
99
+ _atomicWrite(f, list);
100
+ return list;
101
+ });
102
+ }
103
+
104
+ function _run(cmd, cwd) {
105
+ const t0 = Date.now();
106
+ const r = cp.spawnSync(cmd, { cwd, shell: true, encoding: 'utf8', timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
107
+ const out = ((r.stdout || '') + '\n' + (r.stderr || '')).trim();
108
+ const code = r.error ? String(r.error.code || r.error.message) : null;
109
+ return {
110
+ status: (r.status == null ? (r.error ? -1 : 1) : r.status),
111
+ // 1.36.88 (출하전 헌트 #4/#10): ENOBUFS 는 "명령이 실행되지 못한 것"이 아니라 **출력이 한도를 넘어
112
+ // 잘린 것**이다. 종전엔 launcher 실패로 분류해, 수정을 마쳐 통과하는 probe 를 "실행 실패"라는
113
+ // 사실과 다른 사유로 차단했다. 판정 불가는 판정 불가라고 말해야 한다.
114
+ overflow: code === 'ENOBUFS',
115
+ spawnError: (code && code !== 'ENOBUFS') ? code : null,
116
+ timedOut: !!(r.error && String(r.error.code) === 'ETIMEDOUT'),
117
+ output: out,
118
+ sample: out.slice(0, 4000),
119
+ ms: Date.now() - t0,
120
+ };
121
+ }
122
+
123
+ // referee 와 같은 규율: 명령 자체가 실행되지 못한 것은 "버그 재현"이 아니다.
124
+ // **하드 신호만** 본다(spawn 실패 · 127 · 9009). 출력 문자열은 보지 않는다 — 아래 _toolErrorHint 참조.
125
+ function _launcherFailure(r) {
126
+ if (!r) return null;
127
+ if (r.spawnError) return `실행 실패(${r.spawnError})`;
128
+ if (r.status === 127 || r.status === 9009) return `명령을 찾을 수 없음(exit ${r.status})`;
129
+ return null;
130
+ }
131
+
132
+ // 출력 문자열 기반 추정 — **등록 시점의 비차단 경고로만** 쓴다.
133
+ // 1.36.87 실측: 종전에는 출력에 `SyntaxError:` 가 있으면 런처 오류로 단정했다. 그런데 수정을 끝내
134
+ // exit 0 인 probe 라도 앱이 그 문자열을 정상 출력하면(예: 11번가가 JSON 대신 HTML 을 주는 케이스의
135
+ // 재현 로그) `probe_unrunnable` 로 **완료가 막혔다** — 이 기능을 만들게 한 바로 그 도메인에서의 오차단.
136
+ // 게이트 휴리스틱은 false-PASS 로 기울여야지 false-BLOCK 으로 기울면 안 된다.
137
+ function _toolErrorHint(r) {
138
+ if (!r || r.status === 0) return null;
139
+ if (/(?:command not found|not recognized as an internal or external command|cannot find module)/i.test(r.output || '')) {
140
+ return '출력이 도구/경로 오류처럼 보입니다 — 버그 재현이 아닐 수 있으니 probe 를 확인하세요';
141
+ }
142
+ return null;
143
+ }
144
+
145
+ // `leerness bugfix start <ID> --repro "<cmd>" --expect-bad "<신호>"`
146
+ // 수정 **전에** 실행한다. 지금 실패해야 하고, 기대 신호가 출력에 있어야 한다.
147
+ // 이 두 조건이 probe 의 탐지력이다 — 통과해버리는 probe 는 나중에 무엇도 증명하지 못한다.
148
+ function _start(root, id, deps) {
149
+ const { arg, has } = deps;
150
+ const json = !!(has && has('--json'));
151
+ const repro = (arg ? arg('--repro', '') : '') || '';
152
+ const expectBad = (arg ? arg('--expect-bad', '') : '') || '';
153
+ if (!_ID_RE.test(String(id || ''))) { failJson(json, 'invalid_id', `task id 형식이 아님: ${JSON.stringify(id)} (예: T-0007)`); return; }
154
+ // 1.36.88 (출하전 헌트 #3/#19): 존재하지 않는 id 로도 등록에 성공해, 오타 하나면 "보호받는다고 믿는데
155
+ // 실제로는 아무것도 막지 않는" 상태가 됐다. 등록 시점에 tracker 에 있는지 확인한다.
156
+ if (typeof deps._taskExists === 'function' && !deps._taskExists(root, String(id))) {
157
+ failJson(json, 'task_not_found', `progress-tracker 에 없는 task: ${id} — 먼저 leerness task add "<제목>" (오타면 leerness task list 로 확인)`);
158
+ return;
159
+ }
160
+ if (!repro.trim()) { failJson(json, 'missing_repro', '--repro "<버그를 재현하는 명령>" 필요 — 지금 실패해야 합니다'); return; }
161
+ if (!expectBad.trim()) { failJson(json, 'missing_expect', '--expect-bad "<기대 실패 신호>" 필요 — 없으면 무관한 실패와 진짜 재현을 구분할 수 없습니다'); return; }
162
+
163
+ const chk = _loadChecked(root);
164
+ if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부: ${_storePath(root)}`); return; }
165
+
166
+ const r = _run(repro, absRoot(root));
167
+ const lf = _launcherFailure(r);
168
+ const matched = r.output.includes(expectBad);
169
+ const reasons = [];
170
+ if (r.overflow) reasons.push('probe 출력이 한도(8MB)를 넘어 재현 여부를 판정할 수 없습니다 — 출력을 줄이거나 파일로 보내세요');
171
+ if (lf) reasons.push(`${lf} — 심어둔 버그가 아니라 명령이 실행되지 못했습니다`);
172
+ else if (r.status === 0) reasons.push('probe 가 지금 **통과**합니다(exit 0) — 버그가 재현되지 않으면 수정 후 통과는 아무것도 증명하지 못합니다');
173
+ if (!lf && !matched) reasons.push(`출력에 기대 신호 "${expectBad}" 없음 — 무관한 실패일 수 있습니다`);
174
+ // 1.36.87 (codex 26차 #3): **두 번** 돌려 둘 다 실패해야 한다. 한 번만 보면 상태를 남기는 probe —
175
+ // 예: `if(!exists(marker)){write(marker); exit 1}` — 가 등록 때만 실패하고 이후 항상 통과해,
176
+ // 제품 코드를 한 줄도 고치지 않고 완료 게이트를 통과시킨다(1회성 probe = 탐지력 0).
177
+ let r2 = null;
178
+ if (!reasons.length) {
179
+ r2 = _run(repro, absRoot(root));
180
+ if (_launcherFailure(r2) || r2.status === 0 || !r2.output.includes(expectBad)) {
181
+ reasons.push(`두 번째 실행에서 재현되지 않았습니다(exit ${r2.status}) — 상태를 남기는 1회성 probe 는 수정 여부와 무관하게 통과하므로 등록하지 않습니다`);
182
+ }
183
+ }
184
+
185
+ const entry = {
186
+ id: String(id), repro, expectBad,
187
+ baseline: { at: now(), failed: !lf && r.status !== 0, exit: r.status, expectMatched: matched, reproduced: 2, ms: r.ms, sample: reasons.length ? r.sample.slice(0, 400) : undefined },
188
+ rootCause: null, siblingScope: null, previousGuardGap: null,
189
+ };
190
+ if (reasons.length) {
191
+ if (json) { log(JSON.stringify({ ok: false, code: 'probe_not_reproducing', id: entry.id, reasons }, null, 2)); }
192
+ else { warn(`probe 가 버그를 재현하지 못했습니다 — 등록하지 않습니다`); reasons.forEach(x => log(` - ${x}`)); }
193
+ if (typeof process !== 'undefined') process.exitCode = 1;
194
+ return;
195
+ }
196
+ const hint = _toolErrorHint(r); // 비차단 — 등록은 진행하되 사용자에게 알린다
197
+ _upsert(root, entry, deps);
198
+ // 1.36.87 (codex 26차 #1): 게이트는 기본 OFF 다. 토글 상태를 보지 않고 "done 때 통과해야 합니다"라고
199
+ // 쓰면, 실제로는 아무것도 강제되지 않는데 강제된다고 **약속하는 문구**가 된다.
200
+ const gateOn = require('./toggles').toggleOn(root, 'bugfix-receipt');
201
+ if (json) { log(JSON.stringify({ ok: true, id: entry.id, baseline: entry.baseline, gateEnabled: gateOn, hint: hint || undefined }, null, 2)); return; }
202
+ ok(`bugfix probe 등록: ${entry.id} — 지금 재현됨(2회 연속 exit ${r.status} · 신호 일치)`);
203
+ if (hint) warn(` ${hint}`);
204
+ log(` ⚠ 이 명령은 앞으로 \`--status done\` 시 자동 실행됩니다 — 저장 위치: ${_storePath(root)}`);
205
+ if (gateOn) log(` → 수정 후 \`leerness task update ${entry.id} --status done\` 시 같은 probe 가 통과해야 합니다.`);
206
+ else warn(` 이 프로젝트에서 완료 게이트는 **꺼져 있습니다** — 지금은 기록만 되고 done 은 막히지 않습니다.\n 켜기: leerness toggle set bugfix-receipt on`);
207
+ log(` → 근본원인/형제범위 기록: leerness bugfix receipt ${entry.id} --root-cause "..." --siblings "..."`);
208
+ }
209
+
210
+ // `leerness bugfix receipt <ID> --root-cause "..." [--siblings "..." | --siblings-na "사유"] [--previous-gap "..."]`
211
+ // **선언**이다. leerness 는 내용의 진위를 판정하지 않는다 — 존재와 문구만 기록한다.
212
+ function _receipt(root, id, deps) {
213
+ const { arg, has } = deps;
214
+ const json = !!(has && has('--json'));
215
+ const chk = _loadChecked(root);
216
+ if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부: ${_storePath(root)}`); return; }
217
+ const e = chk.list.find(x => x.id === String(id));
218
+ if (!e) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id} — 먼저 leerness bugfix start ${id} --repro "..." --expect-bad "..."`); return; }
219
+ const rc = (arg ? arg('--root-cause', '') : '') || '';
220
+ const sib = (arg ? arg('--siblings', '') : '') || '';
221
+ const sibNa = (arg ? arg('--siblings-na', '') : '') || '';
222
+ const gap = (arg ? arg('--previous-gap', '') : '') || '';
223
+ if (rc.trim()) e.rootCause = rc.trim();
224
+ // 1.36.87 실측: `--siblings ","` 는 checked=[] 를 만들면서 "형제범위 기록됨"으로 통과했다(공허 영수증).
225
+ // 빈 목록은 기록이 아니다 — 거부한다.
226
+ const parsed = sib.trim() ? sib.split(',').map(s => s.trim()).filter(Boolean) : [];
227
+ if (sib.trim() && !parsed.length) { failJson(json, 'empty_siblings', `--siblings 값에 항목이 없습니다: ${JSON.stringify(sib)} — 쉼표로 구분된 형제 지점을 적거나, 없으면 --siblings-na "<사유>"`); return; }
228
+ if (parsed.length) e.siblingScope = { checked: parsed };
229
+ else if (sibNa.trim()) e.siblingScope = { notApplicable: sibNa.trim() };
230
+ if (gap.trim()) e.previousGuardGap = gap.trim();
231
+ _upsert(root, e, deps);
232
+ if (json) { log(JSON.stringify({ ok: true, id: e.id, rootCause: e.rootCause, siblingScope: e.siblingScope, previousGuardGap: e.previousGuardGap }, null, 2)); return; }
233
+ ok(`bugfix 영수증 갱신: ${e.id}`);
234
+ log(` ⓘ 근본원인·형제범위는 **선언**입니다 — leerness 는 내용의 진위를 판정하지 않습니다(검증되는 것은 probe 뿐).`);
235
+ }
236
+
237
+ // `--status done` 전이에서 호출. 토글 OFF 이거나 probe 미등록이면 **아무 영향 없음**(기존 동작 보존).
238
+ // 반환: { blocked, code, message, lines } — 호출부가 렌더/exit 를 결정한다.
239
+ function checkDoneTransition(root, id) {
240
+ const chk = _loadChecked(root);
241
+ if (chk.invalid) {
242
+ return { blocked: true, code: 'store_invalid', message: `bugfix-receipts.json 형상 무효 — 완료 판정을 보류합니다: ${_storePath(root)}` };
243
+ }
244
+ const e = chk.list.find(x => x.id === String(id));
245
+ if (!e) return { blocked: false }; // bugfix 로 선언되지 않은 task 는 무영향
246
+ const r = _run(e.repro, absRoot(root));
247
+ const lf = _launcherFailure(r);
248
+ const lines = [];
249
+ if (lf) return { blocked: true, code: 'probe_unrunnable', message: `재현 probe 를 실행할 수 없습니다 — ${lf}: ${e.repro}` };
250
+ // 출력 초과는 실행 실패가 아니다 — 통과/실패를 **알 수 없는** 상태이므로 그대로 말하고 보류한다(사실과 다른 사유 금지).
251
+ if (r.overflow) {
252
+ return {
253
+ blocked: true, code: 'probe_output_too_large',
254
+ message: `probe 출력이 한도(8MB)를 넘어 통과 여부를 판정할 수 없습니다 — 출력을 줄이거나 파일로 보내세요(예: > out.log): ${e.repro}`,
255
+ };
256
+ }
257
+ if (r.status !== 0) {
258
+ return {
259
+ blocked: true, code: 'probe_still_failing',
260
+ message: `재현 probe 가 아직 실패합니다(exit ${r.status}) — 버그가 남아 있거나 probe 자체가 깨졌습니다(아래 출력 확인): ${e.repro}`,
261
+ lines: [r.sample.split('\n').slice(0, 6).join('\n')],
262
+ };
263
+ }
264
+ const missing = [];
265
+ if (!e.rootCause) missing.push('--root-cause');
266
+ if (!_siblingScopeOk(e.siblingScope)) missing.push('--siblings 또는 --siblings-na');
267
+ if (missing.length) {
268
+ return {
269
+ blocked: true, code: 'receipt_incomplete',
270
+ message: `영수증 미완: ${missing.join(' · ')} — leerness bugfix receipt ${e.id} ${missing.map(m => m.split(' ')[0] + ' "..."').join(' ')}`,
271
+ };
272
+ }
273
+ // baseline 은 _entryValid 가 요구하지만, 렌더는 방어적으로 — 종전엔 여기서 TypeError 로 task update 가 통째로 죽었다(실측).
274
+ const wasExit = (e.baseline && e.baseline.exit != null) ? `수정 전 exit ${e.baseline.exit} → ` : '';
275
+ lines.push(`✓ 재현 probe 통과(${wasExit}지금 exit 0): ${e.repro}`);
276
+ // 1.36.87 (codex 26차 #2): exit 0 인데 등록 당시의 실패 신호가 출력에 그대로 있으면 "증상만 덮은" 신호다.
277
+ // 다만 **막지는 않는다** — 신호가 성공 메시지에 정상 등장할 수 있고, 여기서 잘못 막으면 이미 고쳐진
278
+ // probe 를 재등록할 방법이 없어(등록은 실패를 요구) 사용자가 빠져나올 수 없다. 경고로 노출한다.
279
+ if (e.expectBad && r.output.includes(e.expectBad)) {
280
+ lines.push(`⚠ exit 는 0 이지만 등록 당시 실패 신호 "${e.expectBad}" 가 출력에 아직 있습니다 — probe 가 정말 그 버그를 보고 있는지 확인하세요.`);
281
+ }
282
+ lines.push(`ⓘ 아래는 **선언**이며 내용의 진위는 검증하지 않았습니다:`);
283
+ lines.push(` 근본원인: ${e.rootCause}`);
284
+ lines.push(` 형제범위: ${e.siblingScope.notApplicable ? 'not-applicable — ' + e.siblingScope.notApplicable : (e.siblingScope.checked || []).join(', ')}`);
285
+ if (e.previousGuardGap) lines.push(` 이전 검증이 놓친 이유: ${e.previousGuardGap}`);
286
+ return { blocked: false, lines };
287
+ }
288
+
289
+ // 1.36.88 (출하전 헌트 #1/#17): 게이트를 호출부마다 배선하면 반드시 빠뜨린다 — 실측으로
290
+ // `task sync --from`(TodoWrite 왕복, 하네스가 직접 지시하는 정규 경로)이 게이트를 통째로 우회했다.
291
+ // 그래서 판정은 progress 행을 쓰는 **단일 병목**에서 하고, 막힐 때는 조용히 무시할 수 없도록 던진다.
292
+ // (반환값 무시로 인한 무언 통과가 이 클래스의 재발 양식이다.)
293
+ class BugfixBlocked extends Error {
294
+ constructor(res, id) {
295
+ super((res && res.message) || 'bugfix 완료 게이트에 의해 보류');
296
+ this.name = 'BugfixBlocked';
297
+ this.taskId = String(id);
298
+ this.code = (res && res.code) || 'blocked';
299
+ this.lines = (res && res.lines) || [];
300
+ }
301
+ }
302
+
303
+ function bugfixCmd(root, sub, rest, deps = {}) {
304
+ const { has } = deps;
305
+ root = absRoot(root);
306
+ const json = !!(has && has('--json'));
307
+ if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
308
+ const id = (rest || [])[0];
309
+ if (sub === 'start') return _start(root, id, deps);
310
+ if (sub === 'receipt') return _receipt(root, id, deps);
311
+ // 1.36.87 (codex 26차 #9): 등록을 되돌릴 길이 필요하다 — probe 스크립트가 사라졌거나 계약이 바뀌면
312
+ // done 이 영구히 막히고(등록은 "지금 실패"를 요구하므로 재등록도 불가) 스토어도 무한히 자란다.
313
+ if (sub === 'drop') {
314
+ const chk = _loadChecked(root);
315
+ if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 수정 거부: ${_storePath(root)}`); return; }
316
+ const left = _remove(root, id, deps);
317
+ if (left === null) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id}`); return; }
318
+ if (json) { log(JSON.stringify({ ok: true, id: String(id), remaining: left.length }, null, 2)); return; }
319
+ ok(`bugfix probe 삭제: ${id} (남은 ${left.length}건) — 이 task 는 다시 완료 게이트 무영향이 됩니다`);
320
+ return;
321
+ }
322
+ if (sub === 'list' || !sub) {
323
+ const chk = _loadChecked(root);
324
+ if (json) { log(JSON.stringify({ ok: !chk.invalid, invalid: chk.invalid, receipts: chk.list.map(e => ({ id: e.id, repro: e.repro, baselineFailed: !!(e.baseline && e.baseline.failed), rootCause: e.rootCause, siblingScope: e.siblingScope })) }, null, 2)); return; }
325
+ if (chk.invalid) { warn(`bugfix-receipts.json 형상 무효: ${_storePath(root)}`); return; }
326
+ if (!chk.list.length) { log('등록된 bugfix probe 없음 — leerness bugfix start <T-ID> --repro "..." --expect-bad "..."'); return; }
327
+ // 1.36.88 (출하전 헌트 #12): 목록과 게이트가 **같은 술어**를 써야 한다 — 빈 checked 를 목록은 "기록됨",
328
+ // 게이트는 "영수증 미완"으로 판정해, 사용자가 목록을 보고 통과할 줄 알았다가 막혔다.
329
+ chk.list.forEach(e => log(` ${e.id} probe=${e.repro.slice(0, 50)} 근본원인=${e.rootCause ? '기록됨' : '미기록'} 형제범위=${_siblingScopeOk(e.siblingScope) ? '기록됨' : '미기록'}`));
330
+ return;
331
+ }
332
+ failJson(json, 'unknown_subcommand', `알 수 없는 bugfix 하위명령: ${sub} (가능: start, receipt, drop, list)`);
333
+ }
334
+
335
+ module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked };
package/lib/mcp-tools.js CHANGED
@@ -19,7 +19,7 @@ module.exports = [
19
19
  { name: 'leerness_lessons', requiredTier: 'read-only', description: '1.9.7/54 — 과거 결정·실수 자동 회수 (--auto: 현재 task 키워드 자동 추출)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' }, auto: { type: 'boolean' }, limit: { type: 'number' } } } },
20
20
  { name: 'leerness_task_export', requiredTier: 'project-write', description: '1.9.60/66 — leerness task → Claude Code TodoWrite 호환 JSON (외부 AI 양방향 sync)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } } } },
21
21
  { name: 'leerness_env_check', requiredTier: 'read-only', description: '1.9.71/73 — .env vs .env.example 동기화 검사 (보안: 키만, 값 미노출). exit 1 if 누락 키 있음', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
22
- { name: 'leerness_brainstorm', requiredTier: 'read-only', description: '1.9.16/72/77 — 누적 컨텍스트(decisions+skills+tasks+rules+evidence+lessons+skillHistory+taskLogFails) 자원 회수. 외부 AI가 새 작업 시작 전 호출', inputSchema: { type: 'object', properties: { topic: { type: 'string' }, path: { type: 'string' }, allApps: { type: 'boolean' } }, required: ['topic'] } },
22
+ { name: 'leerness_brainstorm', requiredTier: 'read-only', description: '1.9.16/72/77 · 1.36.86 — 누적 컨텍스트 자원 회수(decisions·skills·tasks·rules·evidence·code(--include-code)·skillHistory·taskLogFails·lessonsExplicit·planMilestones·archive). `total` 은 **고유 자원 수**로, archive 를 포함하고 `lessons`(evidence 의 실패-키워드 파생 뷰)는 중복이라 제외한다. 외부 AI가 새 작업 시작 전 호출', inputSchema: { type: 'object', properties: { topic: { type: 'string' }, path: { type: 'string' }, allApps: { type: 'boolean' } }, required: ['topic'] } },
23
23
  { name: 'leerness_skill_match', requiredTier: 'safe-write', description: '1.9.45/50/83 — 사용자 task 키워드에 매칭되는 설치된 skill 추천 (jaccard 또는 embedding). 1.9.68 rolling history 자동 누적', inputSchema: { type: 'object', properties: { query: { type: 'string' }, path: { type: 'string' }, useEmbedding: { type: 'boolean' } }, required: ['query'] } },
24
24
  { name: 'leerness_skill_list', requiredTier: 'read-only', description: '1.9.84 — 워크스페이스에 설치된 skill 목록 + 사용 횟수 + 출처 (catalog/user). 외부 AI가 사용 가능한 skill 조회', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
25
25
  { name: 'leerness_health', requiredTier: 'read-only', description: '1.9.85/86 — 종합 헬스 체크 (drift + 보안 + skills + MCP + tasks + issues 배열). 외부 AI가 워크스페이스 상태 한 번에 확인', inputSchema: { type: 'object', properties: { path: { type: 'string' }, strict: { type: 'boolean' } } } },
@@ -92,9 +92,9 @@ module.exports = [
92
92
  { name: 'leerness_honesty_check', requiredTier: 'read-only', description: '1.9.305 (사용자 명시) — AI 인식론적 정직성 점검. 주장/evidence 텍스트가 (1) 모르는 걸 아는 척(근거 없는 단정), (2) 검증 없는 섣부른 판단(추정+완료 결론), (3) 외부 정보 미수집(API/버전/스펙 언급+수집흔적 없음) 중 무엇에 해당하는지 휴리스틱 탐지. read-only. 에이전트가 자기 주장을 단언 전 self-check. 인자: { path?, taskId?, text? }. 응답: { ok, findings[], dimensions[], highCount }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, taskId: { type: 'string' }, text: { type: 'string' } } } },
93
93
  { name: 'leerness_brief', requiredTier: 'read-only', description: '1.9.308 (UR-0055) — 프로젝트 청사진 회수. 프로젝트 개요/소개/목적/기능/스택/아키텍처/성공기준/방향이력을 구조화 반환(show) 또는 복사용 blueprint 텍스트(export=true). read-only. 외부 에이전트가 "이 프로젝트가 무엇이고 어디로 가는가"를 1콜로 파악하거나, 신규 프로젝트 계획으로 복사. 인자: { path?, export? }. 응답(show): { project, intro, purpose, features[], stack[], directionHistory[], ... }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, export: { type: 'boolean' } } } },
94
94
  { name: 'leerness_clarify', requiredTier: 'read-only', description: '1.36.77 (UR-0061) — 사용자 요청 텍스트의 판단-모호 신호 감지 → 사용자에게 그대로 물을 질문 목록 JSON ({ ambiguous, signals[], questions[] }). 추측 구현 방지 — 모호하면 질문 먼저.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, path: { type: 'string' } }, required: ['text'] } },
95
- { name: 'leerness_preview', requiredTier: 'safe-write', description: '1.36.77 (UR-0061/0066) — 신규 기능/디자인 미리보기 승인 워크플로. action: add(title/design/features/mockup)·list·show·approve·revise(note 필수)·mockup(HTML 시안 스캐폴드 생성, force 로 재생성). 계약: approve 전 해당 기능 코드 작성 금지. list/show 는 읽기지만 단일 도구라 safe-write 로 보수 선언.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'approve', 'revise', 'mockup'] }, path: { type: 'string' }, title: { type: 'string' }, design: { type: 'string' }, features: { type: 'string' }, mockup: { type: 'string' }, id: { type: 'string' }, note: { type: 'string' }, force: { type: 'boolean' } }, required: ['action'] } },
96
- { name: 'leerness_referee', requiredTier: 'safe-write', description: '1.36.80 (P-0001) — 검증기 캘리브레이션: 검증기를 신뢰하기 전에 탐지력을 실행으로 증명(known-good 통과 + 일부러 망가뜨린 known-bad 를 기대 사유로 거부). action: add(id/check/good/bad/expectBad)·list·show·verify·drop. 명령/기대치가 바뀌면 캘리브레이션이 stale 이 되어 신뢰가 철회된다. verify-claim 의 refereeId 로 게이팅.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'verify', 'drop'] }, path: { type: 'string' }, id: { type: 'string' }, check: { type: 'string' }, good: { type: 'string' }, bad: { type: 'string' }, expectBad: { type: 'string' } }, required: ['action'] } },
97
- ];
95
+ { name: 'leerness_preview', requiredTier: 'safe-write', description: '1.36.77 (UR-0061/0066) — 신규 기능/디자인 미리보기 승인 워크플로. action: add(title/design/features/mockup)·list·show·approve·revise(note 필수)·mockup(HTML 시안 스캐폴드 생성, force 로 재생성). 계약: approve 전 해당 기능 코드 작성 금지. list/show 는 읽기지만 단일 도구라 safe-write 로 보수 선언.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'approve', 'revise', 'mockup'] }, path: { type: 'string' }, title: { type: 'string' }, design: { type: 'string' }, features: { type: 'string' }, mockup: { type: 'string' }, id: { type: 'string' }, note: { type: 'string' }, force: { type: 'boolean' } }, required: ['action'] } },
96
+ { name: 'leerness_referee', requiredTier: 'safe-write', description: '1.36.80 (P-0001) — 검증기 캘리브레이션: 검증기를 신뢰하기 전에 탐지력을 실행으로 증명(known-good 통과 + 일부러 망가뜨린 known-bad 를 기대 사유로 거부). action: add(id/check/good/bad/expectBad)·list·show·verify·drop. 명령/기대치가 바뀌면 캘리브레이션이 stale 이 되어 신뢰가 철회된다. verify-claim 의 refereeId 로 게이팅.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'verify', 'drop'] }, path: { type: 'string' }, id: { type: 'string' }, check: { type: 'string' }, good: { type: 'string' }, bad: { type: 'string' }, expectBad: { type: 'string' } }, required: ['action'] } },
97
+ ];
98
98
 
99
99
  // 1.36.58 (외부 GPT 감사 F-09): core 프로필 — 세션 시작 토큰/도구 선택 비용을 줄이는 핵심 20종.
100
100
  // `leerness mcp serve --profile core` (또는 LEERNESS_MCP_PROFILE=core) 에서 tools/list·tools/call 이 이 집합만 노출.
package/lib/toggles.js CHANGED
@@ -11,25 +11,50 @@ const TOGGLE_REGISTRY = {
11
11
  'lens': { desc: '품질 렌즈 자기질문 (완료 선언 전 분야별 점검)', affects: 'leerness lens' },
12
12
  'auto-graph': { desc: '온톨로지 그래프(leerness.html) 자동 갱신 (install/session-close)', affects: 'install · session close' },
13
13
  'delegation-brief': { desc: '백그라운드 AI 위임 시 leerness 프로토콜 브리프 자동 접두', affects: 'agents dispatch · agents multi' },
14
+ // 1.36.87: **기본 OFF(옵트인)** — 켜면 bugfix 로 선언된 task 는 재현 probe 가 통과해야 done 이 된다.
15
+ // 기존 프로젝트의 `task update --status done` 을 일제히 깨뜨리지 않기 위해 반드시 opt-in 이어야 한다.
16
+ // 1.36.88 (출하전 헌트 #11): 켜기만 하면 뭔가 강제되는 것처럼 읽혔다 — 실제로는 `bugfix start` 로
17
+ // probe 를 등록한 task 에만 적용된다. 광고 문구가 적용 범위를 말해야 한다.
18
+ 'bugfix-receipt': { desc: 'bugfix 완료 게이트 — `leerness bugfix start` 로 probe 를 등록한 task 에 한해, done 시 probe 통과 + 근본원인/형제범위 영수증 요구 (기본 OFF)', affects: 'task update --status done · task sync --from', defaultOff: true },
14
19
  };
15
20
 
16
21
  function _togglesPath(root) { return path.join(absRoot(root), '.harness', 'toggles.json'); }
17
22
 
18
- // 전체 토글 상태 로드 — 파일 없으면 전부 기본 ON. 손상 파일은 읽기에선 기본값(비변경 경로 resilient).
23
+ // 전체 토글 상태 로드 — 파일 없으면 기본값(대부분 ON, `defaultOff` 표시된 것은 OFF).
24
+ // 1.36.87: 기존 동작을 바꾸는 신규 토글은 반드시 defaultOff 여야 한다 — 안 그러면 업그레이드만으로
25
+ // 모든 사용자의 워크플로가 깨진다(기본 ON 은 "새 검사가 조용히 강제됨"을 뜻하므로).
26
+ // 값 해석 — 인식 가능한 표현만 받고, 알 수 없는 값은 레지스트리 기본값으로 되돌린다.
27
+ // 1.36.87: 종전 규칙은 `v !== false` 였다. 기본 ON 토글에서는 무해했지만(어차피 ON), 기본 OFF 인
28
+ // 차단 게이트가 생긴 지금은 `null`/`0`/`"off"` 같은 값이 게이트를 **켜 버린다**(실측 확인).
29
+ // 원칙: 알 수 없는 값은 차단을 켜서도, 보호를 꺼서도 안 된다 → 기본값 유지.
30
+ function _coerceToggle(v, dflt) {
31
+ if (v === true || v === false) return v;
32
+ if (typeof v === 'string') {
33
+ const s = v.trim().toLowerCase();
34
+ if (s === 'on' || s === 'true' || s === '1') return true;
35
+ if (s === 'off' || s === 'false' || s === '0') return false;
36
+ }
37
+ return dflt;
38
+ }
39
+
19
40
  function loadToggles(root) {
20
41
  const out = {};
21
- for (const id of Object.keys(TOGGLE_REGISTRY)) out[id] = true;
42
+ for (const [id, meta] of Object.entries(TOGGLE_REGISTRY)) out[id] = !(meta && meta.defaultOff);
22
43
  const f = _togglesPath(root);
23
44
  if (!exists(f)) return out;
24
45
  try {
25
46
  const j = JSON.parse(read(f));
26
- for (const [k, v] of Object.entries(j || {})) if (k in out) out[k] = v !== false;
47
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return out; // 배열/스칼라 저장본 → 기본값
48
+ for (const [k, v] of Object.entries(j)) if (k in out) out[k] = _coerceToggle(v, out[k]);
27
49
  } catch {}
28
50
  return out;
29
51
  }
30
52
 
31
- // 단일 토글 조회 헬퍼 — 호출부 한 줄용.
32
- function toggleOn(root, id) { return loadToggles(root)[id] !== false; }
53
+ // 단일 토글 조회 헬퍼 — 호출부 한 줄용. defaultOff 토글은 명시적으로 켜야 true.
54
+ function toggleOn(root, id) {
55
+ const st = loadToggles(root);
56
+ return id in st ? st[id] === true : true;
57
+ }
33
58
 
34
59
  function saveToggles(root, toggles) {
35
60
  const f = _togglesPath(root);
@@ -74,4 +99,4 @@ function toggleCmd(root, sub, id, val, deps = {}) {
74
99
  fail(`알 수 없는 하위명령: ${sub} (가능: list, set)`); process.exitCode = 1;
75
100
  }
76
101
 
77
- module.exports = { TOGGLE_REGISTRY, loadToggles, toggleOn, saveToggles, toggleCmd, _togglesPath };
102
+ module.exports = { TOGGLE_REGISTRY, loadToggles, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.85",
3
+ "version": "1.36.88",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",