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/toggles.js
CHANGED
|
@@ -11,25 +11,52 @@ 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 },
|
|
19
|
+
// 1.36.89 (P-0007): 켜도 **자동 실행은 없다** — `route --dispatch` 를 허용할 뿐이고, 고위험은 사람 승인이 별도로 필요하다.
|
|
20
|
+
'difficulty-routing': { desc: '작업 난이도별 모델 라우팅 — `leerness agents route` 의 --confirm 허용(제안/열람은 토글과 무관). 고위험은 --approved-by 필수 (기본 OFF)', affects: 'leerness agents route --confirm', defaultOff: true },
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
function _togglesPath(root) { return path.join(absRoot(root), '.harness', 'toggles.json'); }
|
|
17
24
|
|
|
18
|
-
// 전체 토글 상태 로드 — 파일 없으면
|
|
25
|
+
// 전체 토글 상태 로드 — 파일 없으면 기본값(대부분 ON, `defaultOff` 표시된 것은 OFF).
|
|
26
|
+
// 1.36.87: 기존 동작을 바꾸는 신규 토글은 반드시 defaultOff 여야 한다 — 안 그러면 업그레이드만으로
|
|
27
|
+
// 모든 사용자의 워크플로가 깨진다(기본 ON 은 "새 검사가 조용히 강제됨"을 뜻하므로).
|
|
28
|
+
// 값 해석 — 인식 가능한 표현만 받고, 알 수 없는 값은 레지스트리 기본값으로 되돌린다.
|
|
29
|
+
// 1.36.87: 종전 규칙은 `v !== false` 였다. 기본 ON 토글에서는 무해했지만(어차피 ON), 기본 OFF 인
|
|
30
|
+
// 차단 게이트가 생긴 지금은 `null`/`0`/`"off"` 같은 값이 게이트를 **켜 버린다**(실측 확인).
|
|
31
|
+
// 원칙: 알 수 없는 값은 차단을 켜서도, 보호를 꺼서도 안 된다 → 기본값 유지.
|
|
32
|
+
function _coerceToggle(v, dflt) {
|
|
33
|
+
if (v === true || v === false) return v;
|
|
34
|
+
if (typeof v === 'string') {
|
|
35
|
+
const s = v.trim().toLowerCase();
|
|
36
|
+
if (s === 'on' || s === 'true' || s === '1') return true;
|
|
37
|
+
if (s === 'off' || s === 'false' || s === '0') return false;
|
|
38
|
+
}
|
|
39
|
+
return dflt;
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
function loadToggles(root) {
|
|
20
43
|
const out = {};
|
|
21
|
-
for (const id of Object.
|
|
44
|
+
for (const [id, meta] of Object.entries(TOGGLE_REGISTRY)) out[id] = !(meta && meta.defaultOff);
|
|
22
45
|
const f = _togglesPath(root);
|
|
23
46
|
if (!exists(f)) return out;
|
|
24
47
|
try {
|
|
25
48
|
const j = JSON.parse(read(f));
|
|
26
|
-
|
|
49
|
+
if (!j || typeof j !== 'object' || Array.isArray(j)) return out; // 배열/스칼라 저장본 → 기본값
|
|
50
|
+
for (const [k, v] of Object.entries(j)) if (k in out) out[k] = _coerceToggle(v, out[k]);
|
|
27
51
|
} catch {}
|
|
28
52
|
return out;
|
|
29
53
|
}
|
|
30
54
|
|
|
31
|
-
// 단일 토글 조회 헬퍼 — 호출부 한 줄용.
|
|
32
|
-
function toggleOn(root, id) {
|
|
55
|
+
// 단일 토글 조회 헬퍼 — 호출부 한 줄용. defaultOff 토글은 명시적으로 켜야 true.
|
|
56
|
+
function toggleOn(root, id) {
|
|
57
|
+
const st = loadToggles(root);
|
|
58
|
+
return id in st ? st[id] === true : true;
|
|
59
|
+
}
|
|
33
60
|
|
|
34
61
|
function saveToggles(root, toggles) {
|
|
35
62
|
const f = _togglesPath(root);
|
|
@@ -74,4 +101,4 @@ function toggleCmd(root, sub, id, val, deps = {}) {
|
|
|
74
101
|
fail(`알 수 없는 하위명령: ${sub} (가능: list, set)`); process.exitCode = 1;
|
|
75
102
|
}
|
|
76
103
|
|
|
77
|
-
module.exports = { TOGGLE_REGISTRY, loadToggles, toggleOn, saveToggles, toggleCmd, _togglesPath };
|
|
104
|
+
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.
|
|
3
|
+
"version": "1.36.89",
|
|
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",
|
package/scripts/e2e.js
CHANGED
|
@@ -7,6 +7,8 @@ const path = require('path');
|
|
|
7
7
|
const cp = require('child_process');
|
|
8
8
|
|
|
9
9
|
// 1.9.12: e2e 안정성을 위해 자식 프로세스의 npm 호출 차단 (hang 방지)
|
|
10
|
+
// 1.36.87: 일부 e2e 타임아웃을 상향했다 — 격리 실측(agents list 5.2s · init --language 8.5s) 대비
|
|
11
|
+
// 여유가 3배 미만이라 전체 스위트 부하에서 간헐 초과했다. 검사 대상은 속도가 아니라 동작이다.
|
|
10
12
|
process.env.LEERNESS_OFFLINE = process.env.LEERNESS_OFFLINE || '1';
|
|
11
13
|
// 1.9.284 (UR-0029): e2e 속도 — 기본 roadmap.html(70KB HTML) 자동 생성 OFF (roadmap 전용 테스트 블록만 일시 ON).
|
|
12
14
|
// 대부분의 init/session 테스트는 roadmap 을 검증하지 않으므로 생성 비용 제거 → 5분 내 완료.
|
|
@@ -804,7 +806,7 @@ total++;
|
|
|
804
806
|
{
|
|
805
807
|
// agents list — claude가 환경변수 + PATH 둘 다 충족 시 ready
|
|
806
808
|
const env1 = { ...process.env, LEERNESS_ENABLE_CLAUDE: '1', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_AGY: '0', LEERNESS_ENABLE_GROK: '0', LEERNESS_ENABLE_COPILOT: '0' };
|
|
807
|
-
const r1 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list'], { encoding: 'utf8', timeout:
|
|
809
|
+
const r1 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list'], { encoding: 'utf8', timeout: 90000, env: env1 });
|
|
808
810
|
const okList = r1.status === 0
|
|
809
811
|
&& /외부 AI CLI 오케스트레이션 \(1\.9\.30\)/.test(r1.stdout)
|
|
810
812
|
&& /\| claude \|/.test(r1.stdout)
|
|
@@ -816,7 +818,7 @@ total++;
|
|
|
816
818
|
// 1.9.146: Ollama → 5 · 1.9.268: grok → 6 · 1.9.277: opencode/qwen/aider/goose → 10 CLI
|
|
817
819
|
const offAll = { LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_AGY: '0', LEERNESS_ENABLE_GROK: '0', LEERNESS_ENABLE_OPENCODE: '0', LEERNESS_ENABLE_QWEN: '0', LEERNESS_ENABLE_AIDER: '0', LEERNESS_ENABLE_GOOSE: '0', LEERNESS_ENABLE_COPILOT: '0', LEERNESS_ENABLE_OLLAMA: '0' };
|
|
818
820
|
const env2 = { ...process.env, ...offAll };
|
|
819
|
-
const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list', '--json'], { encoding: 'utf8', timeout:
|
|
821
|
+
const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list', '--json'], { encoding: 'utf8', timeout: 90000, env: env2 });
|
|
820
822
|
let parsed = null;
|
|
821
823
|
try { parsed = JSON.parse(r2.stdout); } catch {}
|
|
822
824
|
const okJson = parsed && Array.isArray(parsed.agents) && parsed.agents.length === 10 && parsed.agents.every(a => a.status !== 'ready');
|
|
@@ -849,7 +851,7 @@ total++;
|
|
|
849
851
|
{
|
|
850
852
|
// agents quota — env=0 시 모두 disabled/not-installed, 안내 메시지 포함
|
|
851
853
|
const env = { ...process.env, LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_AGY: '0', LEERNESS_ENABLE_GROK: '0', LEERNESS_ENABLE_COPILOT: '0' };
|
|
852
|
-
const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota'], { encoding: 'utf8', timeout:
|
|
854
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota'], { encoding: 'utf8', timeout: 90000, env });
|
|
853
855
|
const okText = r.status === 0
|
|
854
856
|
&& /외부 AI CLI quota 추정 \(1\.9\.31\)/.test(r.stdout)
|
|
855
857
|
&& /\| claude \|/.test(r.stdout)
|
|
@@ -859,7 +861,7 @@ total++;
|
|
|
859
861
|
&& /\| copilot \|/.test(r.stdout)
|
|
860
862
|
&& /provider 대시보드 참조/.test(r.stdout);
|
|
861
863
|
// JSON 출력
|
|
862
|
-
const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota', '--json'], { encoding: 'utf8', timeout:
|
|
864
|
+
const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota', '--json'], { encoding: 'utf8', timeout: 90000, env });
|
|
863
865
|
let parsed = null;
|
|
864
866
|
try { parsed = JSON.parse(r2.stdout); } catch {}
|
|
865
867
|
// 1.9.277: opencode/qwen/aider/goose → 10 CLI
|
|
@@ -4639,9 +4641,11 @@ total++;
|
|
|
4639
4641
|
const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 180000 });
|
|
4640
4642
|
const sout = (sr.stdout || '') + (sr.stderr || '');
|
|
4641
4643
|
const selftestOk = sr.status === 0 && /전체 \d+건 통과/.test(sout) && !/설치 손상/.test(sout);
|
|
4642
|
-
const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout:
|
|
4644
|
+
const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 120000 }); // 1.36.87: doctor 는 selftest 를 내장한다(19~24s)
|
|
4643
4645
|
const dout = (dr.stdout || '') + (dr.stderr || '');
|
|
4644
|
-
|
|
4646
|
+
// 1.36.87 (codex 26차 #11): 종료코드를 안 보면 **타임아웃/중단이 통과로 보인다**(spawnSync 타임아웃은 status=null,
|
|
4647
|
+
// 이미 찍힌 "통과" 문자열은 남는다). 제한을 120s 로 올린 만큼 status 단언이 반드시 함께 가야 한다.
|
|
4648
|
+
const doctorOk = dr.status === 0 && !/문제 감지|설치 손상|재설치/.test(dout) && /설치 정상|통과/.test(dout);
|
|
4645
4649
|
ok = selftestOk && doctorOk;
|
|
4646
4650
|
fs.rmSync(ni, { recursive: true, force: true });
|
|
4647
4651
|
} catch {}
|
|
@@ -5058,6 +5062,29 @@ total++;
|
|
|
5058
5062
|
if (!ok) failed++;
|
|
5059
5063
|
}
|
|
5060
5064
|
|
|
5065
|
+
// 1.36.88 (--json 단일 JSON 계약 회귀): selftest --json 은 stdout 에 JSON 1개만 내야 한다 — **리포 루트 cwd** 에서도.
|
|
5066
|
+
// 사각지대: 위 5049 블록은 e2e 기본 tmp cwd 로만 돌아 "실제 하네스가 있는 cwd 에서만 보이는 오염"을 놓친다.
|
|
5067
|
+
// 또한 stderr 잡음 0 을 함께 단언한다 — 잡음의 실제 원인(프로브가 fail() 의 stderr 를 안 가로챔 · graph 케이스 quiet 누락)은
|
|
5068
|
+
// io 래치가 사람용 log 를 stderr 로 강등해 주는 **우연** 덕에 stdout 만 겨우 지켜졌다. 래치가 바뀌면 곧장 stdout 오염이 된다.
|
|
5069
|
+
// → stdout 순수성만 단언하면 "우연히 통과"하므로, 잡음 자체를 0 으로 고정해 원인 단계에서 막는다.
|
|
5070
|
+
total++;
|
|
5071
|
+
{
|
|
5072
|
+
let ok = false;
|
|
5073
|
+
try {
|
|
5074
|
+
const repoRoot = path.resolve(__dirname, '..');
|
|
5075
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { cwd: repoRoot, encoding: 'utf8', timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
|
|
5076
|
+
const pureStdout = typeof r.stdout === 'string' && r.stdout.trimStart().startsWith('{');
|
|
5077
|
+
const j = JSON.parse(r.stdout); // 계약: stdout 전체가 단일 JSON 문서
|
|
5078
|
+
const payloadOk = j.ok === true && j.pass === j.total && j.fail === 0 && j.total > 0;
|
|
5079
|
+
const se = r.stderr || '';
|
|
5080
|
+
// 알려진 유출원 3종: fail() 프로브의 '✗ ' · graph 사람용 3줄(파일경로/노드수/안내)
|
|
5081
|
+
const noiseFree = !se.includes('✗ ') && !se.includes('leerness.html →') && !/nodes · /.test(se);
|
|
5082
|
+
ok = pureStdout && payloadOk && noiseFree && r.status === 0;
|
|
5083
|
+
} catch {}
|
|
5084
|
+
console.log(ok ? '✓ B(1.36.88) selftest --json 계약: 리포 루트 cwd stdout 순수 JSON + stderr 잡음 0' : '✗ selftest --json 계약 실패 (stdout 오염 또는 stderr 잡음)');
|
|
5085
|
+
if (!ok) failed++;
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5061
5088
|
// 1.9.367 회귀 (UR-0025): _mergeEnvLines 모듈 분리 — migrate 가 사용자 .env 값을 key-aware 로 보존 (덮어쓰기 X)
|
|
5062
5089
|
total++;
|
|
5063
5090
|
{
|
|
@@ -6923,9 +6950,13 @@ total++;
|
|
|
6923
6950
|
const drKo = out(cp.spawnSync(process.execPath, [CLI, 'drift', 'check', d], { encoding: 'utf8', timeout: 20000 }));
|
|
6924
6951
|
const driftOk = /signal \| age \| threshold/.test(drEn) && !H.test(drEn) && /신호 \| age \| 임계/.test(drKo);
|
|
6925
6952
|
// ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
const
|
|
6953
|
+
// 1.36.87: doctor 는 내부에서 selftest 를 돌린다 — 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
|
|
6954
|
+
// 제한을 올린 대신 종료코드도 함께 단언한다 — 타임아웃(status=null)이 출력 매칭만으로 통과하면 안 된다(codex 26차 #11).
|
|
6955
|
+
const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 120000, cwd: d });
|
|
6956
|
+
const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 120000, cwd: d });
|
|
6957
|
+
const docEn = out(docEnR), docKo = out(docKoR);
|
|
6958
|
+
const doctorOk = docEnR.status === 0 && docKoR.status === 0
|
|
6959
|
+
&& /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
|
|
6929
6960
|
fs.rmSync(d, { recursive: true, force: true });
|
|
6930
6961
|
// ⑧ (1.29.1) handoff 보안 요약 섹션: .env + 미흡한 .gitignore → en 영어(섹션 라인 한글 0) + ko 기본 한글.
|
|
6931
6962
|
// 소스가드만으로는 못 잡는 회귀를 e2e 로 보강: 보안 요약 블록은 headline 의 t() 스코프 밖이라,
|
|
@@ -6987,19 +7018,22 @@ total++;
|
|
|
6987
7018
|
fs.rmSync(da, { recursive: true, force: true });
|
|
6988
7019
|
// ⑫ (1.30.5 #156 F3+F4) handoff 본문 워크플로 가이드 + 메모리 변동 en 영어(섹션 한글 0) + ko 보존 · verify-claim/optimism-check 미입력 에러 en/ko.
|
|
6989
7020
|
const df3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3-'));
|
|
6990
|
-
cp.spawnSync(process.execPath, [CLI, 'init', df3, '--yes', '--language', 'en'], { encoding: 'utf8', timeout:
|
|
7021
|
+
cp.spawnSync(process.execPath, [CLI, 'init', df3, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 120000 });
|
|
6991
7022
|
const hf3En = out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3], { encoding: 'utf8', timeout: 25000 }));
|
|
6992
7023
|
const wfLines = hf3En.split('\n').filter(l => /Session workflow|Analyze request|sub-agent work|to disable:/.test(l));
|
|
6993
7024
|
const f3En = /Session workflow/.test(hf3En) && wfLines.length >= 3 && !wfLines.some(l => H.test(l));
|
|
6994
7025
|
const df3ko = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3k-'));
|
|
6995
|
-
cp.spawnSync(process.execPath, [CLI, 'init', df3ko, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout:
|
|
7026
|
+
cp.spawnSync(process.execPath, [CLI, 'init', df3ko, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 120000 });
|
|
6996
7027
|
const f3Ko = /세션 워크플로 6단계/.test(out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3ko], { encoding: 'utf8', timeout: 25000 })));
|
|
6997
7028
|
const vcEn = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3, '--language', 'en'], { encoding: 'utf8', timeout: 15000 }));
|
|
6998
7029
|
const vcKo = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3ko], { encoding: 'utf8', timeout: 15000 }));
|
|
6999
7030
|
const f4 = /required\. ex:/.test(vcEn) && !H.test(vcEn) && /필요\. 예:/.test(vcKo);
|
|
7000
7031
|
[df3, df3ko].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
|
|
7001
7032
|
ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
|
|
7002
|
-
|
|
7033
|
+
// 1.36.87: 단언 18개를 && 로 묶고 실패 시 어느 것인지 알려주지 않아, 진단하려면 22분짜리 스위트를 통째로
|
|
7034
|
+
// 다시 돌려야 했다. 다른 블록들과 같은 규율으로 실패 플래그를 노출한다.
|
|
7035
|
+
if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk, hoEnOk, hoKoOk, edEnOk, edKoOk, shEnOk, shKoOk, agEnOk, agKoOk, f3En, f3Ko, f4 }));
|
|
7036
|
+
} catch (e) { console.log(' [i18n 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
7003
7037
|
console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4/1.30.5) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash/워크플로가이드/verify-claim) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
|
|
7004
7038
|
if (!ok) failed++;
|
|
7005
7039
|
}
|
|
@@ -7600,6 +7634,189 @@ total++;
|
|
|
7600
7634
|
if (!ok) failed++;
|
|
7601
7635
|
}
|
|
7602
7636
|
|
|
7637
|
+
// 1.36.88 (출하전 헌트 #1/#2/#3/#13/#17/#18): bugfix 완료 게이트 **배선** 회귀 가드.
|
|
7638
|
+
// selftest 는 checkDoneTransition 을 in-process 로만 불러, `task update --status done` 배선을 한 줄도
|
|
7639
|
+
// 실행하지 않았다 — 게이트를 통째로 죽여도 341/341 그린이었다(변이로 실측). 실제 CLI 경로로 못 박는다.
|
|
7640
|
+
// 특히 done 을 기록하는 경로가 taskUpdate 하나가 아니다: task sync --from(TodoWrite 왕복)이 우회했다.
|
|
7641
|
+
total++;
|
|
7642
|
+
{
|
|
7643
|
+
let ok = false;
|
|
7644
|
+
const _d = [];
|
|
7645
|
+
const R = (args, cwd) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 180000, cwd }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
7646
|
+
let A = false, B = false, C = false, D = false, E = false, F = false, G = false;
|
|
7647
|
+
try {
|
|
7648
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bfgate-')); _d.push(d);
|
|
7649
|
+
R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7650
|
+
fs.mkdirSync(path.join(d, 'src'), { recursive: true });
|
|
7651
|
+
const BUGGY = 'console.log("BUG_HERE"); process.exit(3);';
|
|
7652
|
+
fs.writeFileSync(path.join(d, 'src', 'app.js'), BUGGY);
|
|
7653
|
+
const ID = ((R(['task', 'add', 'fix order status', '--path', d, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
7654
|
+
// G: 토글 OFF(기본)에서는 probe 가 있어도 done 이 막히지 않는다 — 옵트인 계약
|
|
7655
|
+
R(['bugfix', 'start', ID, '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d, '--json']);
|
|
7656
|
+
G = R(['task', 'update', ID, '--status', 'in-progress', '--path', d]).s === 0
|
|
7657
|
+
&& R(['task', 'update', ID, '--status', 'done', '--path', d]).s === 0;
|
|
7658
|
+
// 이후는 토글 ON 상태에서 (별 프로젝트 — G 가 done 을 기록했으므로)
|
|
7659
|
+
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bfgate2-')); _d.push(d2);
|
|
7660
|
+
R(['init', d2, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7661
|
+
R(['toggle', 'set', 'bugfix-receipt', 'on', '--path', d2]);
|
|
7662
|
+
fs.mkdirSync(path.join(d2, 'src'), { recursive: true });
|
|
7663
|
+
fs.writeFileSync(path.join(d2, 'src', 'app.js'), BUGGY);
|
|
7664
|
+
const ID2 = ((R(['task', 'add', 'fix order status', '--path', d2, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
7665
|
+
// F: progress-tracker 에 없는 id 는 등록 거부 — 오타 하나로 "보호받는 줄 아는" 무보호가 되던 구멍
|
|
7666
|
+
F = R(['bugfix', 'start', 'T-9999', '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d2, '--json']).s === 1;
|
|
7667
|
+
R(['bugfix', 'start', ID2, '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d2, '--json']);
|
|
7668
|
+
// A: task update 차단 (대조군). **사유까지** 단언한다 — exit 만 보면 probe 검사와 영수증 검사가
|
|
7669
|
+
// 서로를 가려, 둘 중 하나를 죽여도 다른 하나가 막아서 변이가 안 잡힌다(실측 M6/M7 생존).
|
|
7670
|
+
const ar = R(['task', 'update', ID2, '--status', 'done', '--path', d2, '--json']);
|
|
7671
|
+
A = ar.s === 1 && /"code":\s*"probe_still_failing"/.test(ar.o);
|
|
7672
|
+
// B: task sync --from 우회 차단 + tracker 에 done 이 **기록되지 않아야** (exit 만 보면 놓친다)
|
|
7673
|
+
R(['task', 'export', '--to', path.join(d2, 'todo.json'), '--path', d2]);
|
|
7674
|
+
const todo = JSON.parse(fs.readFileSync(path.join(d2, 'todo.json'), 'utf8'));
|
|
7675
|
+
todo.forEach(t => { if (t.content === 'fix order status') t.status = 'completed'; });
|
|
7676
|
+
fs.writeFileSync(path.join(d2, 'todo.json'), JSON.stringify(todo));
|
|
7677
|
+
const bs = R(['task', 'sync', '--from', path.join(d2, 'todo.json'), '--path', d2]);
|
|
7678
|
+
const tracker = fs.readFileSync(path.join(d2, '.harness', 'progress-tracker.md'), 'utf8');
|
|
7679
|
+
B = bs.s === 1 && !(new RegExp('\\|\\s*' + ID2 + '\\s*\\|\\s*done\\s*\\|').test(tracker));
|
|
7680
|
+
// C: 재오픈 시나리오 — 1라운드에서 이미 done 인 task 가 회귀했을 때도 막아야 한다(이 기능의 동기)
|
|
7681
|
+
const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bfgate3-')); _d.push(d3);
|
|
7682
|
+
R(['init', d3, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7683
|
+
R(['toggle', 'set', 'bugfix-receipt', 'on', '--path', d3]);
|
|
7684
|
+
fs.mkdirSync(path.join(d3, 'src'), { recursive: true });
|
|
7685
|
+
fs.writeFileSync(path.join(d3, 'src', 'app.js'), BUGGY);
|
|
7686
|
+
const ID3 = ((R(['task', 'add', 'fix order status', '--path', d3, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
7687
|
+
R(['task', 'update', ID3, '--status', 'done', '--evidence', 'r1', '--path', d3]); // 1라운드 완료(probe 없음)
|
|
7688
|
+
R(['bugfix', 'start', ID3, '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d3, '--json']);
|
|
7689
|
+
C = R(['task', 'update', ID3, '--status', 'done', '--evidence', 'r2', '--path', d3]).s === 1;
|
|
7690
|
+
// C2: **수정은 했지만 영수증이 없는** 상태 — probe 는 통과하므로 여기서 막는 것은 영수증 검사뿐이다.
|
|
7691
|
+
// 이 단계가 있어야 probe 검사와 영수증 검사가 서로를 가리지 않는다(판별 케이스).
|
|
7692
|
+
fs.writeFileSync(path.join(d3, 'src', 'app.js'), 'process.exit(0);');
|
|
7693
|
+
const c2 = R(['task', 'update', ID3, '--status', 'done', '--path', d3, '--json']);
|
|
7694
|
+
C = C && c2.s === 1 && /"code":\s*"receipt_incomplete"/.test(c2.o);
|
|
7695
|
+
// D: 수정 + 영수증 후에는 통과하고 "선언" 고지가 나온다 — 강화가 정상 흐름을 죽이면 안 된다
|
|
7696
|
+
R(['bugfix', 'receipt', ID3, '--root-cause', '상태 매핑 누락', '--siblings', 'ssg,11st', '--path', d3, '--json']);
|
|
7697
|
+
const dr = R(['task', 'update', ID3, '--status', 'done', '--evidence', 'r3', '--path', d3]);
|
|
7698
|
+
D = dr.s === 0 && /선언/.test(dr.o) && /진위는 검증하지 않았/.test(dr.o);
|
|
7699
|
+
// E: 탈출구 — drop 하면 다시 무영향
|
|
7700
|
+
E = R(['bugfix', 'drop', ID3, '--path', d3, '--json']).s === 0
|
|
7701
|
+
&& R(['task', 'update', ID3, '--status', 'done', '--path', d3]).s === 0;
|
|
7702
|
+
ok = A && B && C && D && E && F && G;
|
|
7703
|
+
if (!ok) console.log(` [bugfix게이트 디버그] update차단(probe사유)=${A} sync차단=${B} 재오픈차단+영수증사유=${C} 정상통과=${D} drop탈출=${E} 없는id거부=${F} 토글OFF무영향=${G}`);
|
|
7704
|
+
} catch (e) { console.log(' [bugfix게이트 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
7705
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
7706
|
+
console.log(ok ? '✓ B(1.36.88) bugfix 완료 게이트 배선: task update·task sync 양 경로 차단 · 재오픈 차단 · 수정후 통과(선언 고지) · drop 탈출 · 없는id 거부 · 토글OFF 무영향' : '✗ bugfix 완료 게이트 배선 실패');
|
|
7707
|
+
if (!ok) failed++;
|
|
7708
|
+
}
|
|
7709
|
+
|
|
7710
|
+
// 1.36.89 (P-0007, 사용자 승인 범위=제안+확인후실행): agents route 난이도 라우팅.
|
|
7711
|
+
// 판별 케이스를 함께 넣는다 — 거부 사유(code)까지 단언하지 않으면 여러 거부 조건이 서로를 가려
|
|
7712
|
+
// 하나를 죽여도 다른 하나가 막는다(1.36.88 에서 변이 6/8 로 실측한 함정).
|
|
7713
|
+
total++;
|
|
7714
|
+
{
|
|
7715
|
+
let ok = false;
|
|
7716
|
+
const _d = [];
|
|
7717
|
+
const R = (args, cwd) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 120000, cwd }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
7718
|
+
const J = (x) => { try { return JSON.parse(x.o.slice(x.o.indexOf('{'))); } catch { return null; } };
|
|
7719
|
+
let C1 = false, C2 = false, C3 = false, C4 = false, D1 = false, D2 = false, D3 = false, D4 = false, D5 = false, H1 = false, H2 = false;
|
|
7720
|
+
try {
|
|
7721
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-route-')); _d.push(d);
|
|
7722
|
+
R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7723
|
+
const cls = (t, extra = []) => J(R(['agents', 'route', t, '--path', d, '--json', ...extra]));
|
|
7724
|
+
// 분류: 고위험 도메인 · 고위험+초소형 충돌은 보수적으로 high-risk · 초소형 · 근거부족은 normal(과소평가 금지)
|
|
7725
|
+
C1 = (cls('결제 환불 로직 수정') || {}).tier === 'high-risk'
|
|
7726
|
+
// codex 28차 #5: 영문 어간(authentication/authorization/permissions/concurrency/idempotency)이
|
|
7727
|
+
// 뒤쪽 \b 때문에 전부 빠져나가 **tiny 로 떨어졌다**(실측). 한글 케이스만 보면 놓친다.
|
|
7728
|
+
&& ['fix typo in authentication middleware', 'fix typo in authorization permissions',
|
|
7729
|
+
'fix typo in concurrency and idempotency control'].every(t => (cls(t) || {}).tier === 'high-risk')
|
|
7730
|
+
// 오탐 대조군: author 는 auth 가 아니고, tokenizer 는 token 이 아니다(과대escalation 방지)
|
|
7731
|
+
&& (cls('fix typo in author bio css') || {}).tier === 'tiny';
|
|
7732
|
+
const conf = cls('인증 문서 오타 수정') || {};
|
|
7733
|
+
C2 = conf.tier === 'high-risk' && conf.conflict === true && conf.confidence === 'low';
|
|
7734
|
+
C3 = (cls('버튼 여백 4px 조정') || {}).tier === 'tiny'
|
|
7735
|
+
// codex 28차 #4: tiny 키워드 하나로 거대 작업이 tiny 가 됐다 — 광범위 신호/긴 설명은 tiny 가 아니다
|
|
7736
|
+
&& (cls('Rewrite the entire compiler, replace the storage engine, and also fix one typo') || {}).tier === 'normal'
|
|
7737
|
+
&& (cls('전체 아키텍처를 갈아엎고 저장 엔진을 교체하고 오타도 수정') || {}).tier === 'normal';
|
|
7738
|
+
const weak = cls('x') || {};
|
|
7739
|
+
C4 = weak.tier === 'normal' && weak.confidence === 'low' // tiny 로 내려가면 안 된다
|
|
7740
|
+
// codex 28차 #3: --tier 를 주면 빈 작업이 통과했다 — 어떤 경로로도 빈 작업은 받지 않는다
|
|
7741
|
+
&& /"code":\s*"empty_task"/.test(R(['agents', 'route', '--tier', 'tiny', '--path', d, '--json']).o);
|
|
7742
|
+
// 사용자 명시 우선 + 잘못된 값 거부
|
|
7743
|
+
const dec = cls('결제 로직 오타', ['--tier', 'tiny']) || {};
|
|
7744
|
+
// 잘못된 --tier 는 **사유(code)까지** 단언한다 — exit 1 만 보면 검증을 없앴을 때 나는 크래시(TIER_ROLES 미정의)와
|
|
7745
|
+
// 구분되지 않아, 검증 제거 변이가 그대로 통과했다(실측 M7 생존).
|
|
7746
|
+
const badTier = R(['agents', 'route', 'x', '--tier', 'huge', '--path', d, '--json']);
|
|
7747
|
+
D1 = dec.tier === 'tiny' && dec.confidence === 'declared' && (dec.reasons || []).some(r => /의도한 것인지/.test(r))
|
|
7748
|
+
&& badTier.s === 1 && /"code":\s*"invalid_tier"/.test(badTier.o);
|
|
7749
|
+
// 토글 OFF → dispatch 거부 (사유까지)
|
|
7750
|
+
const off = R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json']);
|
|
7751
|
+
D2 = off.s === 1 && /"routing_disabled"/.test(off.o);
|
|
7752
|
+
// 토글 ON + 역할 미설정 → 거부 (판별: 토글 거부와 다른 사유여야)
|
|
7753
|
+
R(['toggle', 'set', 'difficulty-routing', 'on', '--path', d]);
|
|
7754
|
+
const nr = R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json']);
|
|
7755
|
+
D3 = nr.s === 1 && /"role_unresolved"/.test(nr.o) && !/"routing_disabled"/.test(nr.o);
|
|
7756
|
+
// 역할 설정 후 normal 은 실행 확정
|
|
7757
|
+
R(['roles', 'set', 'commander', '--provider', 'claude', '--path', d]);
|
|
7758
|
+
R(['roles', 'set', 'coder', '--provider', 'codex', '--path', d]);
|
|
7759
|
+
R(['roles', 'set', 'reviewer', '--provider', 'claude', '--path', d]);
|
|
7760
|
+
const okd = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
|
|
7761
|
+
D4 = okd.confirmed === true && okd.plan && okd.plan.reviewerIndependent === true;
|
|
7762
|
+
// 고위험: 승인자 없으면 fail-closed (판별: 역할은 다 설정된 상태여야 role_unresolved 가 아님이 증명된다)
|
|
7763
|
+
R(['roles', 'set', 'architect', '--provider', 'codex', '--path', d]);
|
|
7764
|
+
const hr = R(['agents', 'route', '결제 환불 로직 수정', '--confirm', '--path', d, '--json']);
|
|
7765
|
+
H1 = hr.s === 1 && /"human_approval_required"/.test(hr.o) && !/"role_unresolved"/.test(hr.o);
|
|
7766
|
+
const hr2 = J(R(['agents', 'route', '결제 환불 로직 수정', '--confirm', '--approved-by', '승인자', '--path', d, '--json'])) || {};
|
|
7767
|
+
D5 = hr2.confirmed === true;
|
|
7768
|
+
// 독립성: 구현자와 검수자가 같은 provider 면 고위험은 거부 (승인자가 있는데도 막혀야 한다 = 판별)
|
|
7769
|
+
R(['roles', 'set', 'reviewer', '--provider', 'codex', '--path', d]);
|
|
7770
|
+
const dep = R(['agents', 'route', '결제 환불 로직 수정', '--confirm', '--approved-by', '승인자', '--path', d, '--json']);
|
|
7771
|
+
H2 = dep.s === 1 && /"reviewer_not_independent"/.test(dep.o) && !/"human_approval_required"/.test(dep.o);
|
|
7772
|
+
// codex 28차 #6: 손으로 편집한 역할 설정의 **빈 provider** 가 "해석됨"으로 통과했다 —
|
|
7773
|
+
// `roles set` 은 빈 값을 안 받으므로 이 상태는 파일 편집으로만 생긴다. 그래서 파일을 직접 만들어 검증한다.
|
|
7774
|
+
const rolesFile = path.join(d, '.harness', 'agent-roles.json');
|
|
7775
|
+
const savedRoles = fs.existsSync(rolesFile) ? fs.readFileSync(rolesFile, 'utf8') : null;
|
|
7776
|
+
// 스키마는 `{roles:{...}}` 다 — 최상위에 쓰면 _loadRoles 가 {} 를 돌려줘 "역할 없음"으로 통과하고,
|
|
7777
|
+
// 빈 provider 를 검증한 것이 아니라 **셋업 실패를 통과로 읽는다**(실측으로 잡았다).
|
|
7778
|
+
fs.writeFileSync(rolesFile, JSON.stringify({ schemaVersion: 1, roles: { coder: { provider: ' ' } } }));
|
|
7779
|
+
const emptyProv = R(['agents', 'route', '버튼 여백 4px 조정', '--confirm', '--path', d, '--json']);
|
|
7780
|
+
const emptyProvJ = J(emptyProv) || {};
|
|
7781
|
+
// 셋업이 유효했는지 함께 단언: coder 가 실제로 목록에 있어야 한다(없으면 다른 이유로 막힌 것)
|
|
7782
|
+
const emptyProvOk = emptyProv.s === 1 && /"role_unresolved"/.test(emptyProv.o)
|
|
7783
|
+
&& ((emptyProvJ.plan || {}).assignments || []).some(a => a.role === 'coder' && a.resolved === false);
|
|
7784
|
+
if (savedRoles !== null) fs.writeFileSync(rolesFile, savedRoles); else fs.rmSync(rolesFile, { force: true });
|
|
7785
|
+
// 감사 기록 — codex 28차 #10: 종전엔 `total >= 8` 만 봐서 **전 항목이 {} 여도 통과**했다. 내용을 본다.
|
|
7786
|
+
const lg = J(R(['agents', 'route', '--log', '--path', d, '--json'])) || {};
|
|
7787
|
+
const ents = lg.entries || [];
|
|
7788
|
+
const auditOk = (lg.total || 0) >= 8
|
|
7789
|
+
&& ents.some(e => e.tier === 'high-risk' && e.confirmed === true && e.approvedBy === '승인자'
|
|
7790
|
+
&& Array.isArray(e.assignments) && e.assignments.some(a => a.role === 'coder' && a.provider)
|
|
7791
|
+
&& Array.isArray(e.signals) && e.signals.some(s => /^high-risk:/.test(s)))
|
|
7792
|
+
&& ents.some(e => e.confirmed === false && Array.isArray(e.refusals) && e.refusals.length > 0); // 거부된 시도도 남는다
|
|
7793
|
+
// 손상 감사로그를 조용히 덮어쓰지 않는다(codex 28차 #8)
|
|
7794
|
+
const lp = path.join(d, '.harness', 'routing-log.json');
|
|
7795
|
+
const savedLog = fs.readFileSync(lp, 'utf8');
|
|
7796
|
+
fs.writeFileSync(lp, '{"소중한":"기록"}');
|
|
7797
|
+
R(['agents', 'route', '무해한 조회', '--path', d]);
|
|
7798
|
+
const auditPreserved = fs.readFileSync(lp, 'utf8') === '{"소중한":"기록"}';
|
|
7799
|
+
fs.writeFileSync(lp, savedLog);
|
|
7800
|
+
// 정직성: 하지 않은 확인을 했다고 말하지 않고, 실행하지 않는다는 사실을 사람용·JSON·--log 모두에서 말한다
|
|
7801
|
+
const human = R(['agents', 'route', '주문 필터', '--path', d]).o;
|
|
7802
|
+
const logHuman = R(['agents', 'route', '--log', '--path', d]).o;
|
|
7803
|
+
const cj = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
|
|
7804
|
+
const honestOk = /확인하지 않았습니다/.test(human) && /측정값이 아닙니다/.test(human)
|
|
7805
|
+
&& !/설치·활성 여부만 확인/.test(human) // 하지 않은 확인을 했다고 말하던 옛 문구
|
|
7806
|
+
&& /확인하지 않았습니다/.test(logHuman) && /외부 CLI 를 실행하지 않습니다/.test(logHuman)
|
|
7807
|
+
&& cj.executed === false && typeof cj.confirmed === 'boolean' && !('dispatched' in cj)
|
|
7808
|
+
&& /외부 CLI 를 실행하지 않습니다/.test(JSON.stringify(cj));
|
|
7809
|
+
// 모델명 비하드코딩 — 정책 모듈에 벤더 모델 id 가 있으면 안 된다(몇 달이면 낡는다)
|
|
7810
|
+
// 경로는 CLI 기준으로 잡는다 — 검사 대상이 "지금 실행 중인 그 설치본"이어야 한다(__dirname 은 실행 위치에 따라 어긋난다).
|
|
7811
|
+
const srcOk = !/(claude-(opus|sonnet|haiku)|gpt-[45]|o[34]-mini|gemini-)/i.test(fs.readFileSync(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'), 'utf8'));
|
|
7812
|
+
ok = C1 && C2 && C3 && C4 && D1 && D2 && D3 && D4 && D5 && H1 && H2 && auditOk && auditPreserved && honestOk && srcOk && emptyProvOk;
|
|
7813
|
+
if (!ok) console.log(` [route 디버그] C1=${C1} C2=${C2} C3=${C3} C4=${C4} D1=${D1} OFF거부=${D2} 역할미설정거부=${D3} normal실행=${D4} 고위험실행=${D5} 승인필요=${H1} 독립성=${H2} 감사=${auditOk} 손상보존=${auditPreserved} 정직=${honestOk} 소스=${srcOk} 빈provider=${emptyProvOk} log=${lg.total}`);
|
|
7814
|
+
} catch (e) { console.log(' [route 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
7815
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
7816
|
+
console.log(ok ? '✓ B(1.36.89) agents route 난이도 라우팅: 결정적 분류(충돌시 보수적·근거부족시 normal) · 토글OFF/역할미설정/사람승인/검수독립성 4중 fail-closed · 감사기록 · 모델명 비하드코딩 · 미검증 고지' : '✗ agents route 라우팅 실패');
|
|
7817
|
+
if (!ok) failed++;
|
|
7818
|
+
}
|
|
7819
|
+
|
|
7603
7820
|
// 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
|
|
7604
7821
|
total++;
|
|
7605
7822
|
{
|