leerness 1.36.86 → 1.36.89
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/CHANGELOG.md +93 -0
- package/README.md +4 -4
- package/bin/leerness.js +222 -28
- package/lib/bugfix.js +335 -0
- package/lib/routing.js +279 -0
- package/lib/toggles.js +33 -6
- package/package.json +1 -1
- package/scripts/e2e.js +229 -12
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/routing.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// lib/routing.js — 작업 난이도별 모델 라우팅 (1.36.89, P-0007 · 사용자 승인 범위 = **제안 + 확인 후 실행**).
|
|
2
|
+
//
|
|
3
|
+
// 사용자 요청: "codex/claude 등의 CLI 설치·사용 가능 여부에 따라 작업 난이도별 AI 모델을 라우팅, 켜고 끌 수 있게".
|
|
4
|
+
//
|
|
5
|
+
// 검토(codex 27차 + 자체)에서 **기각/제한한 것**:
|
|
6
|
+
// · 자유 텍스트로부터의 **조용한 자동 라우팅** — 잘못된 등급이 조용히 선택되면 사용자는 알 수 없고,
|
|
7
|
+
// 비싼 등급이 선택되면 과금이 발생한다. 그래서 기본은 제안이고, 실행은 명시 확인(--dispatch)이 필요하다.
|
|
8
|
+
// · **모델 이름 하드코딩** — 벤더 모델은 몇 달마다 바뀐다. 정책은 추상 역할(architect/commander/coder/reviewer)로만
|
|
9
|
+
// 쓰고, 역할→provider+model 매핑은 사용자 설정(`leerness roles set`)에 맡긴다.
|
|
10
|
+
// · **"설치됨 = 사용 가능"** — `--version` 이 도는 것은 인증·모델 사용권한·쿼터·비대화형 동작·과금 권한을
|
|
11
|
+
// 아무것도 증명하지 않는다. 그래서 상태를 그대로 라벨링하고, 검증하지 않은 것은 검증하지 않았다고 적는다.
|
|
12
|
+
// · **변경 파일 수 기준 라우팅** — 일이 끝나야 알 수 있는 값이라 초기 라우팅 근거가 될 수 없다.
|
|
13
|
+
//
|
|
14
|
+
// 정직성 계약: 여기서 판정하는 것은 **텍스트에 대한 결정적 규칙 매칭**뿐이다. 난이도를 "측정"하지 않는다.
|
|
15
|
+
// 규칙이 겹치거나(오타 수정인데 인증 도메인) 신호가 약하면 그 사실을 그대로 노출하고 보수적으로 normal 이상을 준다.
|
|
16
|
+
'use strict';
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { absRoot, exists, read, writeUtf8, mkdirp, log, ok, warn, failJson, now } = require('./io');
|
|
19
|
+
|
|
20
|
+
const TIERS = ['tiny', 'normal', 'high-risk'];
|
|
21
|
+
|
|
22
|
+
// 고위험 도메인 — 결정적 규칙. 여기 걸리면 tiny 로 내려가지 않는다(과대평가는 비용, 과소평가는 사고).
|
|
23
|
+
// 한글은 단어경계(\b)가 동작하지 않으므로 부분일치를 쓰고, 영문만 경계를 건다.
|
|
24
|
+
// 1.36.89 (codex 28차 #5): 종전엔 `\b(auth|permission|concurren|idempoten)\b` 처럼 **뒤쪽 \b** 를 붙여
|
|
25
|
+
// `authentication` · `authorization` · `permissions` · `concurrency` · `idempotency` 가 전부 매치되지 않았다.
|
|
26
|
+
// 그 결과 "fix typo in authentication middleware" 가 **tiny** 로 떨어졌다(실측). 어간 + `\w*` 로 바꾼다.
|
|
27
|
+
// 오탐 가드: `author`(글쓴이)는 매치되면 안 되므로 authentic/authoriz 어간을 따로 적고,
|
|
28
|
+
// `tokenizer`(파서 용어)를 피하려 token 뒤 `iz` 를 제외한다.
|
|
29
|
+
const HIGH_RISK = [
|
|
30
|
+
{ id: 'auth', ko: '인증·인가', re: /\b(auth|authn|authz|authentic\w*|authoriz\w*|authoris\w*|login|log in|logout|sign in|session\w*|token(?!iz)\w*|jwt|oauth|sso|permission\w*|privilege\w*|rbac|acl)\b|인증|인가|로그인|세션|토큰|권한/i },
|
|
31
|
+
{ id: 'payment', ko: '결제·정산', re: /\b(payment\w*|billing|checkout|invoice\w*|refund\w*|subscription\w*|stripe|paypal)\b|결제|정산|환불|청구|구독/i },
|
|
32
|
+
{ id: 'data-migration', ko: '데이터 마이그레이션·스키마', re: /\b(migrat\w*|schema\w*|alter table|drop table|backfill\w*)\b|마이그레이션|스키마|백필/i },
|
|
33
|
+
{ id: 'pii', ko: '개인정보', re: /\b(pii|gdpr|personal data|ssn|passport)\b|개인정보|주민등록|여권/i },
|
|
34
|
+
{ id: 'secrets', ko: '시크릿·키', re: /\b(secret\w*|credential\w*|api key|private key|password\w*)\b|시크릿|비밀번호|자격증명|비밀키/i },
|
|
35
|
+
{ id: 'public-api', ko: '공개 API 계약', re: /\b(public api|breaking change\w*|api contract|versioning)\b|공개 api|하위호환|호환성 깨/i },
|
|
36
|
+
{ id: 'concurrency', ko: '동시성·트랜잭션', re: /\b(concurren\w*|transaction\w*|race condition\w*|deadlock\w*|idempoten\w*|mutex|semaphore)\b|동시성|트랜잭션|경쟁 상태|교착|멱등/i },
|
|
37
|
+
{ id: 'infra', ko: '인프라·배포', re: /\b(deploy\w*|infra\w*|terraform|kubernetes|k8s|dockerfile|ci\/cd|pipeline\w*)\b|배포|인프라|파이프라인/i },
|
|
38
|
+
{ id: 'destructive', ko: '파괴적 작업', re: /\b(delete all|truncate\w*|rm -rf|purge\w*|wipe\w*)\b|전체 삭제|일괄 삭제|초기화/i },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// 초소형 — **범위가 명시적으로 좁을 때만**. 고위험 신호가 하나라도 있으면 적용하지 않는다.
|
|
42
|
+
const TINY = [
|
|
43
|
+
{ id: 'typo', ko: '오타·문구', re: /\b(typo\w*|wording|copy fix)\b|오타|문구 수정|띄어쓰기/i },
|
|
44
|
+
{ id: 'rename', ko: '이름 변경', re: /\b(rename|변수명|함수명)\b|이름 변경|네이밍/i },
|
|
45
|
+
{ id: 'style', ko: '포맷·린트·CSS', re: /\b(lint\w*|format\w*|prettier|eslint|css|padding|margin|색상)\b|포맷|린트|여백|스타일/i },
|
|
46
|
+
{ id: 'comment', ko: '주석·문서 문구', re: /\b(comment\w*|docstring\w*)\b|주석/i },
|
|
47
|
+
{ id: 'single-test', ko: '테스트 1건', re: /\b(add a test|one test|테스트 한|테스트 1)\b/i },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// 1.36.89 (codex 28차 #4): 초소형 키워드가 **하나라도** 있으면 tiny 로 떨어지던 것 —
|
|
51
|
+
// "Rewrite the entire compiler, replace the storage engine, and also fix one typo" 가 tiny 였다(실측).
|
|
52
|
+
// tiny 는 "범위가 좁다"는 주장이므로, 광범위 신호가 있거나 설명이 길면 그 주장을 인정하지 않는다.
|
|
53
|
+
const BROAD = /\b(entire|whole|all |everything|rewrite|overhaul|refactor all|across the|end.to.end)\b|전체|전면|모든|갈아엎|재작성|통째/i;
|
|
54
|
+
const TINY_MAX_LEN = 80;
|
|
55
|
+
|
|
56
|
+
function _norm(s) { return String(s == null ? '' : s).trim(); }
|
|
57
|
+
|
|
58
|
+
// 사용자에게 반복 노출되는 고지 — **이 모듈이 실제로 한 일만** 말한다.
|
|
59
|
+
// 1.36.89 (codex 28차 #7): 종전 문구는 "설치·활성 여부만 확인했습니다"였는데, 이 모듈은 역할 매핑만 읽고
|
|
60
|
+
// 설치 확인(`--version`)을 **한 번도 하지 않는다**. 하지 않은 확인을 했다고 말하는 것이 바로 이 제품이 잡는 결함이다.
|
|
61
|
+
const _AVAIL_NOTE = '이 명령은 역할 매핑(leerness roles)만 읽습니다 — 해당 CLI 의 설치·인증·모델 사용권한·쿼터·과금 권한은 확인하지 않았습니다(확인: leerness agents check).';
|
|
62
|
+
const _CLASS_NOTE = '난이도는 텍스트 규칙 매칭 결과이며 측정값이 아닙니다.';
|
|
63
|
+
const _EXEC_NOTE = 'leerness 는 여기서 외부 CLI 를 실행하지 않습니다 — 계획을 확정하고 기록할 뿐입니다.';
|
|
64
|
+
|
|
65
|
+
// 난이도 판정 — 순서가 곧 정책이다. (1) 사용자 명시 (2) 고위험 도메인 (3) 초소형 (4) 기본 normal
|
|
66
|
+
function classify(text, opts = {}) {
|
|
67
|
+
const t = _norm(text);
|
|
68
|
+
const declared = _norm(opts.tier).toLowerCase();
|
|
69
|
+
const high = HIGH_RISK.filter(r => r.re.test(t));
|
|
70
|
+
const tiny = TINY.filter(r => r.re.test(t));
|
|
71
|
+
const signals = [
|
|
72
|
+
...high.map(r => ({ kind: 'high-risk', id: r.id, ko: r.ko })),
|
|
73
|
+
...tiny.map(r => ({ kind: 'tiny', id: r.id, ko: r.ko })),
|
|
74
|
+
];
|
|
75
|
+
const reasons = [];
|
|
76
|
+
|
|
77
|
+
// 1.36.89 (codex 28차 #3): 빈 작업 검사가 declared 분기 **뒤에** 있어, `--tier tiny` 만 주면
|
|
78
|
+
// 빈 작업이 통과했다(실측). 어떤 경로로도 빈 작업은 받지 않는다.
|
|
79
|
+
if (!t) return { ok: false, code: 'empty_task', error: '작업 설명이 비어 있습니다 — leerness agents route "<작업 설명>"' };
|
|
80
|
+
|
|
81
|
+
if (declared) {
|
|
82
|
+
if (!TIERS.includes(declared)) return { ok: false, code: 'invalid_tier', error: `--tier 는 ${TIERS.join('|')} (받음: ${declared})` };
|
|
83
|
+
reasons.push(`사용자가 --tier ${declared} 로 명시 — 규칙 판정보다 우선합니다`);
|
|
84
|
+
if (declared !== 'high-risk' && high.length) {
|
|
85
|
+
reasons.push(`⚠ 고위험 신호(${high.map(r => r.ko).join(', ')})가 있는데 ${declared} 로 지정됐습니다 — 의도한 것인지 확인하세요`);
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, tier: declared, confidence: 'declared', signals, reasons, conflict: declared !== 'high-risk' && high.length > 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (high.length) {
|
|
91
|
+
// 고위험 + 초소형 신호 동시 매칭(예: "인증 문서 오타 수정")은 규칙만으로 못 가른다 — 사실을 노출하고 보수적으로 간다.
|
|
92
|
+
const conflict = tiny.length > 0;
|
|
93
|
+
reasons.push(`고위험 도메인 규칙 매칭: ${high.map(r => r.ko).join(', ')}`);
|
|
94
|
+
if (conflict) reasons.push(`동시에 초소형 신호(${tiny.map(r => r.ko).join(', ')})도 있습니다 — 규칙으로는 가릴 수 없어 보수적으로 high-risk 로 둡니다. 사소한 작업이면 --tier tiny 로 낮추세요`);
|
|
95
|
+
return { ok: true, tier: 'high-risk', confidence: conflict ? 'low' : 'high', signals, reasons, conflict };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 신호가 없고 설명이 짧으면 판정 근거가 없는 것이다 — tiny 로 내리지 않고 normal 로 둔다(과소평가 금지).
|
|
99
|
+
if (!tiny.length) {
|
|
100
|
+
const weak = t.length < 12;
|
|
101
|
+
reasons.push(weak
|
|
102
|
+
? '매칭된 규칙이 없고 설명이 짧습니다 — 판정 근거가 부족해 기본값 normal 로 둡니다'
|
|
103
|
+
: '매칭된 고위험/초소형 규칙 없음 — 기본값 normal');
|
|
104
|
+
return { ok: true, tier: 'normal', confidence: weak ? 'low' : 'medium', signals, reasons, conflict: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// tiny 는 "범위가 좁다"는 주장이다 — 광범위 신호가 있거나 설명이 길면 그 주장을 인정하지 않는다.
|
|
108
|
+
const broad = BROAD.test(t);
|
|
109
|
+
const longText = t.length > TINY_MAX_LEN;
|
|
110
|
+
if (broad || longText) {
|
|
111
|
+
reasons.push(`초소형 신호(${tiny.map(r => r.ko).join(', ')})가 있지만 ${broad ? '광범위 신호가 함께 있어' : `설명이 ${t.length}자로 길어(기준 ${TINY_MAX_LEN}자)`} 좁은 범위로 볼 수 없습니다 — normal 로 둡니다`);
|
|
112
|
+
return { ok: true, tier: 'normal', confidence: 'low', signals, reasons, conflict: true };
|
|
113
|
+
}
|
|
114
|
+
reasons.push(`초소형 신호 매칭: ${tiny.map(r => r.ko).join(', ')} · 고위험/광범위 신호 없음`);
|
|
115
|
+
return { ok: true, tier: 'tiny', confidence: 'medium', signals, reasons, conflict: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 등급별 역할 구성 — **추상 역할 id 만** 쓴다(모델 이름 없음). 매핑은 `leerness roles set` 이 소유한다.
|
|
119
|
+
const TIER_ROLES = {
|
|
120
|
+
tiny: { roles: ['coder'], humanApproval: false, ko: '작업자 1명 + 기존 테스트' },
|
|
121
|
+
normal: { roles: ['commander', 'coder', 'reviewer'], humanApproval: false, ko: '지휘·구현·검수' },
|
|
122
|
+
'high-risk': { roles: ['architect', 'commander', 'coder', 'reviewer'], humanApproval: true, ko: '설계 선행 · 독립 검수 · 사람 승인' },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// 라우팅 계획 — 역할을 사용자 설정으로 해석하고, 해석 못 한 역할과 독립성 결함을 그대로 보고한다.
|
|
126
|
+
function plan(root, cls, deps = {}) {
|
|
127
|
+
// 알 수 없는 등급은 여기서 크래시할 게 아니라 가장 보수적인 구성으로 떨어뜨린다 —
|
|
128
|
+
// 상류 검증이 사라져도 "조용히 느슨해지는" 대신 가장 엄격한 쪽으로 실패하게(fail-closed).
|
|
129
|
+
const spec = TIER_ROLES[cls.tier] || TIER_ROLES['high-risk'];
|
|
130
|
+
const resolve = typeof deps._resolveRole === 'function' ? deps._resolveRole : () => null;
|
|
131
|
+
// 1.36.89 (codex 28차 #6): 종전엔 `resolved: !!r` 라 provider 가 빈 문자열이어도 "해석됨"이었다 —
|
|
132
|
+
// 빈 provider 로 dispatch 가 통과하고 `coder:` 로 표시됐다(실측). 실제로 쓸 수 있는 값인지까지 본다.
|
|
133
|
+
const assignments = spec.roles.map(role => {
|
|
134
|
+
const r = resolve(root, role);
|
|
135
|
+
const provider = r && typeof r.provider === 'string' ? r.provider.trim() : '';
|
|
136
|
+
return { role, provider: provider || null, model: (r && r.model) || null, resolved: !!provider };
|
|
137
|
+
});
|
|
138
|
+
const unresolved = assignments.filter(a => !a.resolved).map(a => a.role);
|
|
139
|
+
const coder = assignments.find(a => a.role === 'coder');
|
|
140
|
+
const reviewer = assignments.find(a => a.role === 'reviewer');
|
|
141
|
+
// 독립 검수: 같은 provider 가 구현과 검수를 겸하면 "검수됨"이라고 말할 수 없다(같은 맹점을 공유한다).
|
|
142
|
+
const reviewerIndependent = !reviewer || !reviewer.resolved || !coder || !coder.resolved
|
|
143
|
+
? null
|
|
144
|
+
: reviewer.provider !== coder.provider;
|
|
145
|
+
const blockers = [];
|
|
146
|
+
if (unresolved.length) blockers.push({ code: 'role_unresolved', message: `역할 미설정: ${unresolved.join(', ')} — leerness roles set <역할> --provider <id>` });
|
|
147
|
+
if (spec.humanApproval && reviewerIndependent === false) blockers.push({ code: 'reviewer_not_independent', message: `검수자와 구현자가 같은 provider(${coder.provider})입니다 — 고위험 작업에서는 독립 검수라고 볼 수 없습니다` });
|
|
148
|
+
return { tier: cls.tier, ko: spec.ko, humanApproval: spec.humanApproval, assignments, unresolved, reviewerIndependent, blockers };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _logPath(root) { return path.join(absRoot(root), '.harness', 'routing-log.json'); }
|
|
152
|
+
const _LOG_CAP = 200;
|
|
153
|
+
|
|
154
|
+
const _LOG_MAX_BYTES = 4 * 1024 * 1024; // codex 28차 #9: 파싱 전에 크기를 본다 — 거대 파일을 동기로 읽어 CLI 가 멎으면 안 된다
|
|
155
|
+
|
|
156
|
+
function _loadLog(root) {
|
|
157
|
+
const f = _logPath(root);
|
|
158
|
+
if (!exists(f)) return { list: [], invalid: false };
|
|
159
|
+
try {
|
|
160
|
+
const sz = require('fs').statSync(f).size;
|
|
161
|
+
if (sz > _LOG_MAX_BYTES) return { list: [], invalid: true, reason: `파일이 너무 큽니다(${Math.round(sz / 1048576)}MB > 4MB)` };
|
|
162
|
+
const j = JSON.parse(read(f));
|
|
163
|
+
if (!Array.isArray(j)) return { list: [], invalid: true, reason: '최상위가 배열이 아닙니다' };
|
|
164
|
+
return { list: j, invalid: false };
|
|
165
|
+
} catch (e) { return { list: [], invalid: true, reason: 'JSON 파싱 실패' }; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 감사 기록 — 무엇을 보고 무엇을 골랐는지 남지 않으면 사후에 라우팅을 검증할 수 없다.
|
|
169
|
+
// 1.36.89 (codex 28차 #8): 손상 파일을 `[rec]` 으로 **조용히 덮어썼다**(실측) — 감사 기록을 파괴하는
|
|
170
|
+
// 동작이 감사 기록 기능 안에 있었다. 손상 시에는 쓰지 않고 그 사실을 반환한다(스토어 손상 = 덮어쓰기 거부, 기존 규율).
|
|
171
|
+
function _appendLog(root, rec, deps = {}) {
|
|
172
|
+
const f = _logPath(root);
|
|
173
|
+
const write = () => {
|
|
174
|
+
const cur = _loadLog(root);
|
|
175
|
+
if (cur.invalid) return { written: false, invalid: true, reason: cur.reason || '형상 무효' };
|
|
176
|
+
const list = cur.list.concat([rec]).slice(-_LOG_CAP);
|
|
177
|
+
mkdirp(path.dirname(f));
|
|
178
|
+
const tmp = f + '.tmp' + process.pid;
|
|
179
|
+
writeUtf8(tmp, JSON.stringify(list, null, 2) + '\n');
|
|
180
|
+
require('fs').renameSync(tmp, f);
|
|
181
|
+
return { written: true, invalid: false, total: list.length };
|
|
182
|
+
};
|
|
183
|
+
return (typeof deps._withLock === 'function') ? deps._withLock(f, write) : write();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// `leerness agents route "<작업>" [--tier x] [--dispatch] [--approved-by "이름"] [--json]`
|
|
187
|
+
function routeCmd(root, taskText, deps = {}) {
|
|
188
|
+
const { has, arg } = deps;
|
|
189
|
+
root = absRoot(root);
|
|
190
|
+
const json = !!(has && has('--json'));
|
|
191
|
+
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
|
|
192
|
+
|
|
193
|
+
if (has && has('--log')) return _showLog(root, json);
|
|
194
|
+
|
|
195
|
+
const cls = classify(taskText, { tier: (arg ? arg('--tier', '') : '') || '' });
|
|
196
|
+
if (!cls.ok) { failJson(json, cls.code, cls.error); return; }
|
|
197
|
+
const p = plan(root, cls, deps);
|
|
198
|
+
|
|
199
|
+
const wantDispatch = !!(has && (has('--confirm') || has('--dispatch')));
|
|
200
|
+
const approvedBy = _norm(arg ? arg('--approved-by', '') : '');
|
|
201
|
+
const gateOn = typeof deps._toggleOn === 'function' ? deps._toggleOn(root, 'difficulty-routing') : false;
|
|
202
|
+
|
|
203
|
+
// 실행 거부 사유 — 하나라도 있으면 dispatch 하지 않는다(고위험은 fail-closed).
|
|
204
|
+
const refusals = [];
|
|
205
|
+
if (wantDispatch && !gateOn) refusals.push({ code: 'routing_disabled', message: '난이도 라우팅 토글이 꺼져 있습니다 — leerness toggle set difficulty-routing on' });
|
|
206
|
+
if (wantDispatch && p.blockers.length) p.blockers.forEach(b => refusals.push(b));
|
|
207
|
+
if (wantDispatch && p.humanApproval && !approvedBy) refusals.push({ code: 'human_approval_required', message: '고위험 작업은 사람 승인이 필요합니다 — --approved-by "<승인자>"' });
|
|
208
|
+
|
|
209
|
+
// 1.36.89 (codex 28차 #1): 종전 필드명은 `dispatched` 였는데 이 모듈은 **외부 CLI 를 실행하지 않는다** —
|
|
210
|
+
// 실행했다고 보고하면서 아무것도 실행하지 않는, 이 제품이 가장 경계하는 형태의 거짓말이었다(실측).
|
|
211
|
+
// 실제로 하는 일은 "계획을 확정하고 실행 명령을 내주는 것"이므로 그대로 `confirmed` 라고 부른다.
|
|
212
|
+
const confirmed = wantDispatch && !refusals.length;
|
|
213
|
+
const rec = {
|
|
214
|
+
at: now(),
|
|
215
|
+
task: _norm(taskText).slice(0, 300),
|
|
216
|
+
tier: cls.tier, confidence: cls.confidence, conflict: !!cls.conflict,
|
|
217
|
+
signals: cls.signals.map(s => `${s.kind}:${s.id}`),
|
|
218
|
+
assignments: p.assignments.map(a => ({ role: a.role, provider: a.provider, model: a.model })),
|
|
219
|
+
reviewerIndependent: p.reviewerIndependent,
|
|
220
|
+
requested: wantDispatch ? 'confirm' : 'suggest',
|
|
221
|
+
confirmed, executed: false, approvedBy: approvedBy || null,
|
|
222
|
+
refusals: refusals.map(r => r.code),
|
|
223
|
+
};
|
|
224
|
+
const logged = _appendLog(root, rec, deps) || {};
|
|
225
|
+
|
|
226
|
+
if (json) {
|
|
227
|
+
log(JSON.stringify({
|
|
228
|
+
ok: !wantDispatch || confirmed, tier: cls.tier, confidence: cls.confidence, conflict: !!cls.conflict,
|
|
229
|
+
signals: cls.signals, reasons: cls.reasons, plan: p, gateEnabled: gateOn,
|
|
230
|
+
confirmed, executed: false, refusals,
|
|
231
|
+
auditWritten: logged.written !== false, auditWarning: logged.invalid ? `감사 기록에 남기지 못했습니다(${logged.reason}) — ${_logPath(root)}` : undefined,
|
|
232
|
+
availabilityNote: _AVAIL_NOTE,
|
|
233
|
+
classificationNote: _CLASS_NOTE,
|
|
234
|
+
executionNote: _EXEC_NOTE,
|
|
235
|
+
}, null, 2));
|
|
236
|
+
if (wantDispatch && !confirmed) process.exitCode = 1;
|
|
237
|
+
return { cls, plan: p, confirmed };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
log(`# leerness agents route — 난이도 ${cls.tier} (신뢰도 ${cls.confidence}${cls.conflict ? ' · 규칙 충돌' : ''})`);
|
|
241
|
+
cls.reasons.forEach(r => log(` · ${r}`));
|
|
242
|
+
log(`\n 구성(${p.ko}):`);
|
|
243
|
+
p.assignments.forEach(a => log(` ${a.role.padEnd(10)} ${a.resolved ? `${a.provider}${a.model ? ' / ' + a.model : ''}` : '⚠ 미설정 — leerness roles set ' + a.role + ' --provider <id>'}`));
|
|
244
|
+
if (p.reviewerIndependent === false) log(` ⚠ 검수자와 구현자가 같은 provider — 독립 검수가 아닙니다`);
|
|
245
|
+
log(`\n ⓘ ${_AVAIL_NOTE}`);
|
|
246
|
+
log(` ⓘ ${_CLASS_NOTE} 틀렸다면 --tier ${TIERS.join('|')} 로 지정하세요.`);
|
|
247
|
+
if (logged.invalid) warn(` 감사 기록에 남기지 못했습니다(${logged.reason}) — ${_logPath(root)} 를 고치거나 삭제하세요`);
|
|
248
|
+
if (!wantDispatch) {
|
|
249
|
+
log(`\n → 이 구성을 확정: leerness agents route "<작업>" --confirm${p.humanApproval ? ' --approved-by "<승인자>"' : ''}${gateOn ? '' : ' (먼저 leerness toggle set difficulty-routing on)'}`);
|
|
250
|
+
} else if (!confirmed) {
|
|
251
|
+
warn(`확정하지 않았습니다:`);
|
|
252
|
+
refusals.forEach(r => log(` - ${r.message}`));
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
} else {
|
|
255
|
+
ok(`라우팅 확정 — ${p.assignments.filter(a => a.resolved).map(a => `${a.role}:${a.provider}`).join(' · ')}`);
|
|
256
|
+
log(` ⓘ ${_EXEC_NOTE}`);
|
|
257
|
+
log(` 아래 명령을 직접 실행하세요:`);
|
|
258
|
+
p.assignments.filter(a => a.resolved).forEach(a => log(` leerness agents dispatch "<${a.role} 작업 계약>" --role ${a.role}`));
|
|
259
|
+
log(` ⓘ 기록: ${_logPath(root)} (leerness agents route --log 로 열람)`);
|
|
260
|
+
}
|
|
261
|
+
return { cls, plan: p, confirmed };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _showLog(root, json) {
|
|
265
|
+
const cur = _loadLog(root);
|
|
266
|
+
// codex 28차 #7: 고지가 --log 경로에는 없어서, 기록만 보는 사용자는 "provider/model 이 검증된 값"으로 읽었다.
|
|
267
|
+
if (json) { log(JSON.stringify({ ok: !cur.invalid, invalid: cur.invalid, reason: cur.reason, total: cur.list.length, entries: cur.list.slice(-20), availabilityNote: _AVAIL_NOTE, executionNote: _EXEC_NOTE }, null, 2)); if (cur.invalid) process.exitCode = 1; return; }
|
|
268
|
+
if (cur.invalid) { warn(`routing-log.json 형상 무효(${cur.reason}): ${_logPath(root)}`); process.exitCode = 1; return; }
|
|
269
|
+
if (!cur.list.length) { log('라우팅 기록 없음 — leerness agents route "<작업 설명>"'); return; }
|
|
270
|
+
log(`# leerness agents route --log (최근 ${Math.min(20, cur.list.length)} / 총 ${cur.list.length})`);
|
|
271
|
+
cur.list.slice(-20).forEach(e => {
|
|
272
|
+
const state = e.executed ? '실행' : e.confirmed ? '확정' : (e.requested === 'confirm' || e.requested === 'dispatch') ? '거부' : '제안';
|
|
273
|
+
log(` ${String(e.at).slice(0, 16)} ${String(e.tier).padEnd(9)} ${String(e.confidence).padEnd(8)} ${state} ${String(e.task).slice(0, 46)}`);
|
|
274
|
+
});
|
|
275
|
+
log(`\n ⓘ ${_AVAIL_NOTE}`);
|
|
276
|
+
log(` ⓘ ${_EXEC_NOTE}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { routeCmd, classify, plan, TIERS, TIER_ROLES, HIGH_RISK, TINY, _logPath, _loadLog };
|