leerness 1.36.104 → 1.36.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/leerness.js +308 -23
- package/lib/diagnostics.js +20 -1
- package/lib/pure-utils.js +7 -0
- package/lib/toggles.js +13 -1
- package/package.json +1 -1
- package/scripts/e2e.js +200 -2
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.106 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
126
126
|
|
|
127
127
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
128
128
|
|
|
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
|
|
|
176
176
|
|
|
177
177
|
### MCP server (외부 AI 통합)
|
|
178
178
|
|
|
179
|
-
Leerness v1.36.
|
|
179
|
+
Leerness v1.36.106는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
180
180
|
|
|
181
181
|
```jsonc
|
|
182
182
|
// 카테고리별
|
|
@@ -197,7 +197,7 @@ Leerness v1.36.104는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
197
197
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
198
198
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
199
199
|
|
|
200
|
-
현재 누적: **v1.9.x → 1.36.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.106 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
|
|
|
235
235
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
236
236
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
237
237
|
|
|
238
|
-
Last synced by Leerness v1.36.
|
|
238
|
+
Last synced by Leerness v1.36.106: 2026-08-07
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.106';
|
|
38
38
|
|
|
39
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -250,7 +250,7 @@ const _VALUE_FLAGS = new Set(['--language','--skills','--path','--status','--pro
|
|
|
250
250
|
// 1.36.103 일괄 등록 — 소스의 arg()/argAll() 소비자에서 기계 추출한 미등록 45종(각각이 위 사고와 같은 오염 지점이었다)
|
|
251
251
|
'--alias','--bin','--cmd','--constraint','--desc','--design','--direction','--emit','--env-flag','--exit','--features','--file','--github','--in','--install-hint','--kind','--lang','--max','--measure','--min','--mockup','--name','--npm-otp','--npm-tag','--ollama-url','--persona','--preset','--presets','--profile','--project','--result','--scenario','--select','--selector','--source','--summary','--task','--text','--tier','--url','--version-args','--where','--window','--x','--y',
|
|
252
252
|
// bin/ 만 훑었을 때 놓친 lib/*.js 소비자 — 특히 --priority 는 _TASK_VALUE_FLAGS 에만 있고 여기 없어 값이 제목에 붙었다
|
|
253
|
-
'--files-changed','--files-read','--members','--only','--personas','--priority','--purpose','--schedule',
|
|
253
|
+
'--files-changed','--files-read','--members','--only','--personas','--priority','--purpose','--schedule','--mode',
|
|
254
254
|
// --approved-by 는 `agents route` 가 값으로 쓴다(자체 인라인 목록에만 있었다) — 부울로 오분류하면 값이 토큰으로 샌다.
|
|
255
255
|
// --title 도 같다(_TASK_VALUE_FLAGS 소속). 둘 다 완전성 가드가 잡아냈다 — 손으로 분류한 것은 손으로 틀린다.
|
|
256
256
|
'--approved-by', '--title']);
|
|
@@ -609,6 +609,34 @@ function skillLock(skills, root) {
|
|
|
609
609
|
}
|
|
610
610
|
|
|
611
611
|
// 1.9.276/1.9.369 (UR-0025): MINIMAL_SKIP_KEYS → lib/catalogs (init --minimal 제외 키).
|
|
612
|
+
// ── 1.36.105 (P-0015, 사용자 승인): 운영 등급 — 절차가 아니라 **읽히는 양**에 등급을 건다.
|
|
613
|
+
// 실측 동기: `init --minimal` 은 디스크 파일만 54→33개로 줄이고 세션 적재는 2,800→2,790 tok(-0.3%) 로 그대로였다.
|
|
614
|
+
// 토글 6종도 전부 '동작' 만 끈다 — 컨텍스트에 대한 레버가 없었다. 게다가 적재는 나이에 비례해 자란다:
|
|
615
|
+
// 신규 2,800 tok → 427라운드 프로젝트 16,341 tok(5.8배, AGENTS.md 7.6→19KB · CLAUDE.md 1→14.7KB).
|
|
616
|
+
// 등급은 무엇을 '실행' 하나가 아니라 무엇을 '읽히나' 를 정한다. 최소 등급에서도 핵심 3종
|
|
617
|
+
// (모델 독립 상태 · 완료 증거 검증 · 인수인계)은 끄지 않는다.
|
|
618
|
+
// 1.36.105 (codex 검수 HIGH#2): 처음엔 strict 를 넣고 "standard + 독립 검수/승인 게이트" 라고 안내했는데,
|
|
619
|
+
// 생성물도 동작도 standard 와 **바이트 동일**이었다 — 이름만 있는 등급이었다. 없는 것을 광고하지 않는다.
|
|
620
|
+
// 두 등급이 정직하게 도는 편이 셋 중 하나가 거짓말하는 것보다 낫다. strict 는 실제 게이트를 갖출 때 다시 연다.
|
|
621
|
+
const _MODES = ['minimal', 'standard'];
|
|
622
|
+
function _normMode(v) { const s = String(v || '').toLowerCase(); return _MODES.includes(s) ? s : 'standard'; }
|
|
623
|
+
// `--mode` 는 **등급을 정하는 명령에서만** 읽는다(init · mode).
|
|
624
|
+
// codex 검수 HIGH#1: 전역으로 읽으면 `handoff . --mode minimal` 처럼 무관한 명령에 붙은 플래그가
|
|
625
|
+
// 등급 미지정 프로젝트의 출력을 바꾼다 — 영속되지 않아도 놀람이고, "미지정=standard" 라는 승인 조건을 흐린다.
|
|
626
|
+
function _modeFlagAllowed() {
|
|
627
|
+
try { const c = (process.argv[2] || ''); return c === 'init' || c === 'mode'; } catch { return false; }
|
|
628
|
+
}
|
|
629
|
+
function _projectMode(root) {
|
|
630
|
+
try {
|
|
631
|
+
if (_modeFlagAllowed()) {
|
|
632
|
+
const flag = String(arg('--mode', '') || '').toLowerCase();
|
|
633
|
+
if (_MODES.includes(flag)) return flag;
|
|
634
|
+
}
|
|
635
|
+
const mf = path.join(absRoot(root || process.cwd()), '.harness', 'manifest.json');
|
|
636
|
+
if (exists(mf)) return _normMode((JSON.parse(read(mf)) || {}).mode);
|
|
637
|
+
} catch {}
|
|
638
|
+
return 'standard'; // 기존 프로젝트는 무변경 — 매니페스트에 mode 가 없으면 standard
|
|
639
|
+
}
|
|
612
640
|
function coreFiles(root, lang = 'ko', selectedSkills = [], opts = {}) {
|
|
613
641
|
const project = detectProjectName(root);
|
|
614
642
|
const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
|
|
@@ -620,7 +648,8 @@ function coreFiles(root, lang = 'ko', selectedSkills = [], opts = {}) {
|
|
|
620
648
|
'.github/copilot-instructions.md': `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .harness/ as project memory.\nDo not remove protected Leerness files.\nBefore completion, ensure plan.md, progress-tracker.md, current-state.md, session-handoff.md are updated.\n`,
|
|
621
649
|
'.harness/HARNESS_VERSION': VERSION + '\n',
|
|
622
650
|
'.harness/LANGUAGE': lang + '\n',
|
|
623
|
-
|
|
651
|
+
// 1.36.105 (P-0015): mode 를 매니페스트에 기록. 기본은 standard = 종전 동작과 바이트 동일.
|
|
652
|
+
'.harness/manifest.json': JSON.stringify({ project, leernessVersion: VERSION, language: lang, mode: _normMode(opts.mode), installedAt: now(), minimal: !!opts.minimal }, null, 2) + '\n',
|
|
624
653
|
'.harness/skills-lock.json': skillLock(selectedSkills, root),
|
|
625
654
|
'.harness/project-brief.md': fm('project-brief', ['프로젝트 목적 확인','신규 기능 판단','계획 수립'], ['프로젝트 목적 변경','사용자/범위 변경'], `# Project Brief\n\n## Project\n${project}\n\n## Purpose\n- 이 프로젝트의 목적을 실제 내용으로 업데이트하세요.\n\n## Users\n-\n\n## Success Criteria\n-\n`),
|
|
626
655
|
'.harness/plan.md': fm('plan', ['작업 시작 전','새 요청 접수','범위 변경','신규 프로젝트 감지'], ['계획 추가/수정/드랍','milestone 변경','목표 변경'], `# Plan\n\n## Goal\n- 사용자 목적을 기준으로 전체 계획을 유지합니다.\n\n## Scope\n- 포함 범위를 기록합니다.\n\n## Out of Scope / Dropped\n| ID | Item | Reason | Date |\n|---|---|---|---|\n\n## Milestones\n\n### M-0001. 프로젝트 계획 정리\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] project-brief.md를 실제 프로젝트 목적에 맞게 작성\n- [ ] context-map.md를 실제 파일 구조에 맞게 작성\n`),
|
|
@@ -849,8 +878,40 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
|
|
|
849
878
|
}
|
|
850
879
|
// 1.9.276: minimal 모드 — 코어가 요구하지 않는 파일 제외 (verify 필수 파일은 유지).
|
|
851
880
|
if (opts.minimal) { for (const k of MINIMAL_SKIP_KEYS) delete _files[k]; }
|
|
881
|
+
// 1.36.105 (P-0015): mode=minimal 은 **AI 가 매 세션 읽는 지침 자체**를 줄인다.
|
|
882
|
+
// --minimal(파일 수)과 다른 축이다 — 그쪽은 세션 적재를 0.3% 밖에 못 줄였다(실측).
|
|
883
|
+
// 남기는 것은 핵심 3종뿐: 모델 독립 상태 · 완료 증거 검증 · 인수인계. verify-claim/session close 는 여기서도 필수다.
|
|
884
|
+
if (_normMode(opts.mode) === 'minimal') {
|
|
885
|
+
_files['AGENTS.md'] = `${MARK}\n` + (lang === 'en' ? _AGENTS_MINIMAL_EN : _AGENTS_MINIMAL_KO);
|
|
886
|
+
_files['CLAUDE.md'] = `${MARK}\n` + (lang === 'en'
|
|
887
|
+
? `# Claude Code Instructions\n\nFollow AGENTS.md.\n`
|
|
888
|
+
: `# Claude Code Instructions\n\nAGENTS.md 를 따르세요.\n`);
|
|
889
|
+
}
|
|
852
890
|
return _files;
|
|
853
891
|
}
|
|
892
|
+
// 최소 등급 지침 — 목표는 "짧다" 가 아니라 "끄면 안 되는 것만 남긴다".
|
|
893
|
+
const _AGENTS_MINIMAL_KO = `# Leerness Agent Instructions (minimal)
|
|
894
|
+
|
|
895
|
+
이 프로젝트는 **최소 등급**입니다. 규약은 셋뿐입니다.
|
|
896
|
+
|
|
897
|
+
1. **세션 시작**: \`leerness handoff .\` — 이전 세션의 상태/결정을 받는다.
|
|
898
|
+
2. **완료 주장 전**: \`leerness verify-claim <T-ID>\` — 증거 없이 "완료" 라고 말하지 않는다.
|
|
899
|
+
3. **세션 종료**: \`leerness session close .\` — 다음 세션으로 상태를 넘긴다.
|
|
900
|
+
|
|
901
|
+
상태는 \`.harness/\` 안의 일반 파일이다(모델이 바뀌어도 남는다). 보호 파일은 삭제하지 않는다.
|
|
902
|
+
더 필요해지면 \`leerness mode set standard\` 로 올린다.
|
|
903
|
+
`;
|
|
904
|
+
const _AGENTS_MINIMAL_EN = `# Leerness Agent Instructions (minimal)
|
|
905
|
+
|
|
906
|
+
This project runs in **minimal mode**. There are only three rules.
|
|
907
|
+
|
|
908
|
+
1. **Session start**: \`leerness handoff .\` — pick up state and decisions from the last session.
|
|
909
|
+
2. **Before claiming done**: \`leerness verify-claim <T-ID>\` — never say "done" without evidence.
|
|
910
|
+
3. **Session end**: \`leerness session close .\` — hand state to the next session.
|
|
911
|
+
|
|
912
|
+
State lives as plain files in \`.harness/\` (it survives a change of model). Do not delete protected files.
|
|
913
|
+
Raise the tier with \`leerness mode set standard\` when you need more.
|
|
914
|
+
`;
|
|
854
915
|
|
|
855
916
|
function copyRecursiveSafe(src, dst) {
|
|
856
917
|
if (!exists(src)) return;
|
|
@@ -923,7 +984,11 @@ function createBackup(root, reason, files, dry = false) {
|
|
|
923
984
|
|
|
924
985
|
// 1.9.1 P2 / 1.9.368 (UR-0025): MERGE_OVERWRITE_FILES → lib/catalogs, managedMerge 순수 코어 → lib/pure-utils. 얇은 래퍼.
|
|
925
986
|
function managedMerge(file, next, previous, archiveDir, mergeOpts = {}) {
|
|
926
|
-
|
|
987
|
+
// 1.36.105 (codex 검수 HIGH#4): 경로를 **cwd 기준**으로 만들어 프로젝트 파일 안에 박았다 —
|
|
988
|
+
// 다른 디렉토리에서 실행하면 "전체 원본 백업: <경로>" 가 존재하지 않는 곳을 가리킨다(검수 재현).
|
|
989
|
+
// 그 문장은 "삭제하지 않았다" 는 약속의 근거이므로 반드시 프로젝트 기준이어야 한다.
|
|
990
|
+
const _base = mergeOpts && mergeOpts.root ? mergeOpts.root : process.cwd();
|
|
991
|
+
const archiveRel = archiveDir ? path.relative(_base, archiveDir).replace(/\\/g, '/') : '.harness/archive';
|
|
927
992
|
return _managedMerge(file, next, previous, archiveRel, MERGE_OVERWRITE_FILES, mergeOpts); // 1.36.60: altTemplate/lang 전달
|
|
928
993
|
}
|
|
929
994
|
|
|
@@ -935,7 +1000,7 @@ function writeIfSafe(root, file, content, opts = {}) {
|
|
|
935
1000
|
// 병합을 우회해 CLAUDE.md 커스텀 지시가 archive 에만 남았다(1.36.28 보존 재설계의 force 구멍).
|
|
936
1001
|
if (already && opts.mergeManaged) {
|
|
937
1002
|
const prev = read(p);
|
|
938
|
-
writeUtf8(p, managedMerge(file, content, prev, opts.archiveDir, { altTemplate: opts.altTemplate, lang: opts.lang }));
|
|
1003
|
+
writeUtf8(p, managedMerge(file, content, prev, opts.archiveDir, { altTemplate: opts.altTemplate, lang: opts.lang, root: opts.root || root }));
|
|
939
1004
|
return { action: 'merged', file };
|
|
940
1005
|
}
|
|
941
1006
|
if (already && !opts.force) return { action: 'preserved', file };
|
|
@@ -1183,7 +1248,7 @@ async function install(root, opts = {}) {
|
|
|
1183
1248
|
if (resolved.permissionMode) log(`Agent 권한 모드: ${resolved.permissionMode} (1.9.174 — REPL에서 \`:permissions extended|full\` 로 즉시 변경 가능)`);
|
|
1184
1249
|
// 1.9.10: 스킬 카탈로그 출처 안내
|
|
1185
1250
|
// 1.9.184 (사용자 명시): leerness-skillpack 미사용 정책 — 안내 메시지 제거. builtin catalog 만 사용.
|
|
1186
|
-
const files = coreFiles(root, lang, skills, { minimal: !!opts.minimal });
|
|
1251
|
+
const files = coreFiles(root, lang, skills, { minimal: !!opts.minimal, mode: opts.mode });
|
|
1187
1252
|
// 1.36.60 (F-05 2회차, 검수 #1 High): 반대-언어 canonical 템플릿 — 언어 전환 재init/마이그레이션 시
|
|
1188
1253
|
// 구 언어 템플릿 라인을 "사용자 커스텀"으로 오인해 통째로 Preserved 에 이월하던 것을 차감 병합으로 해소.
|
|
1189
1254
|
// (검수 2차 High): 차감은 "실제 언어 전환일 때만" — 동일-언어 재init 에 무조건 적용하면 반대-언어 템플릿과
|
|
@@ -1279,7 +1344,7 @@ async function install(root, opts = {}) {
|
|
|
1279
1344
|
actions.push({ file:f, action });
|
|
1280
1345
|
continue;
|
|
1281
1346
|
}
|
|
1282
|
-
const r = writeIfSafe(root, f, c, { force: effForce, mergeManaged, archiveDir: backup.archiveDir, altTemplate: _altFiles[f], lang });
|
|
1347
|
+
const r = writeIfSafe(root, f, c, { force: effForce, mergeManaged, archiveDir: backup.archiveDir, altTemplate: _altFiles[f], lang, root });
|
|
1283
1348
|
actions.push(r);
|
|
1284
1349
|
_done++;
|
|
1285
1350
|
drawProgress(_done, f);
|
|
@@ -3242,13 +3307,36 @@ function pathSetupCmd(root, opts = {}) {
|
|
|
3242
3307
|
// 실측 비용: init 1330ms · 초기화된 디렉토리 복사 78ms(17배 저렴) · CLI 1회 기동 434ms.
|
|
3243
3308
|
// → 언어별로 **한 번만** init 하고 이후는 복사해서 쓴다. 각 호출자는 자기 사본을 받으므로 상호 오염이 없다.
|
|
3244
3309
|
const _stFixtureCache = new Map();
|
|
3310
|
+
// 1.36.106 (T-0090): 캐시된 base 는 호출자가 지울 수 없어 **프로세스마다 언어 수만큼 남았다**(실측 누적 3,190개).
|
|
3311
|
+
// selftest/doctor 는 CI 에서 반복 실행되는 명령이라 그대로 두면 사용자 tmpdir 이 무한히 자란다.
|
|
3312
|
+
// 프로세스 종료 시 한 번에 치운다 — 캐시의 수명이 곧 프로세스 수명이므로 exit 훅이 정확한 자리다.
|
|
3313
|
+
let _stCleanupHooked = false;
|
|
3314
|
+
function _stSweepFixtures() {
|
|
3315
|
+
for (const b of _stFixtureCache.values()) {
|
|
3316
|
+
if (!b) continue;
|
|
3317
|
+
try { fs.rmSync(b, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ }
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
function _stHookCleanup() {
|
|
3321
|
+
if (_stCleanupHooked) return;
|
|
3322
|
+
_stCleanupHooked = true;
|
|
3323
|
+
process.on('exit', _stSweepFixtures);
|
|
3324
|
+
// codex 검수 MEDIUM#1: Ctrl+C 는 'exit' 리스너를 거치지 않는다(SIGINT 리스너가 없으면 기본 종료가 즉시 죽인다).
|
|
3325
|
+
// CI 에서 중단되는 일이 드물지 않으므로 SIGINT 도 잡아 치우고 표준 종료코드로 나간다.
|
|
3326
|
+
// 이 훅은 selftest/doctor 경로에서만 등록된다(대화형 prompt 가 있는 명령에는 걸리지 않는다).
|
|
3327
|
+
process.on('SIGINT', () => { _stSweepFixtures(); process.exit(130); });
|
|
3328
|
+
}
|
|
3245
3329
|
function _selftestFixture(lang) {
|
|
3246
3330
|
const key = lang || 'ko';
|
|
3247
3331
|
if (!_stFixtureCache.has(key)) {
|
|
3332
|
+
_stHookCleanup();
|
|
3248
3333
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_stfx_' + key + '_'));
|
|
3249
3334
|
const args = [__filename, 'init', base, '--yes', '--no-env', '--no-stale-check'];
|
|
3250
3335
|
if (key !== 'ko') args.push('--language', key);
|
|
3251
3336
|
const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 });
|
|
3337
|
+
// codex 검수 MEDIUM#2: init 실패 시 캐시에 null 을 넣는데, 그러면 **이미 만든 base 가 훅의 시야에서 사라진다**.
|
|
3338
|
+
// 실패 경로야말로 정리가 필요한 자리다 — 그 자리에서 바로 지운다.
|
|
3339
|
+
if (r.status !== 0) { try { fs.rmSync(base, { recursive: true, force: true }); } catch {} }
|
|
3252
3340
|
_stFixtureCache.set(key, r.status === 0 ? base : null);
|
|
3253
3341
|
}
|
|
3254
3342
|
const base = _stFixtureCache.get(key);
|
|
@@ -4201,13 +4289,19 @@ function _selfTestCases() {
|
|
|
4201
4289
|
const encJson = src.includes('function encodingCheck(root, opts = {})');
|
|
4202
4290
|
const contractExit = src.includes('if (!okJson) process.exitCode = 1;');
|
|
4203
4291
|
let behav = false;
|
|
4204
|
-
|
|
4292
|
+
// 1.36.106 (T-0090): 대조군 디렉토리를 익명으로 만들어 **정리할 수 없었다** — 실측 tmpdir 누적 4,574개.
|
|
4293
|
+
// selftest 는 CI 친화 명령으로 광고되므로 커밋마다 쌓인다. 이름을 붙여 finally 에서 함께 지운다.
|
|
4294
|
+
// codex 검수 LOW#3: 둘을 try 밖에서 만들면 **두 번째 생성이 실패할 때 첫 번째가 남는다**(ENOSPC 등).
|
|
4295
|
+
// 생성을 try 안으로 넣어 어느 쪽이 실패해도 finally 가 만들어진 것만 정확히 치우게 한다.
|
|
4296
|
+
let tmp = null, cleanDir = null;
|
|
4205
4297
|
try {
|
|
4298
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sec_'));
|
|
4299
|
+
cleanDir = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_clean_'));
|
|
4206
4300
|
fs.writeFileSync(path.join(tmp, 'c.js'), 'module.exports={apiKey:"sk-test-1234567890abcdefghijklmnopqrstuvwxyz"};');
|
|
4207
4301
|
const r = _collectSecretFindings(tmp);
|
|
4208
|
-
const clean = _collectSecretFindings(
|
|
4302
|
+
const clean = _collectSecretFindings(cleanDir);
|
|
4209
4303
|
behav = r.committed.length >= 1 && clean.committed.length === 0;
|
|
4210
|
-
} catch {} finally { try { fs.rmSync(
|
|
4304
|
+
} catch {} finally { for (const _d of [tmp, cleanDir]) { if (_d) { try { fs.rmSync(_d, { recursive: true, force: true }); } catch {} } } }
|
|
4211
4305
|
return handoffWired && scanJson && encJson && contractExit && behav;
|
|
4212
4306
|
} },
|
|
4213
4307
|
{ name: '9라운드 (UR-0119/0120): team review(메인 검수) — _composeTeamPlan reviewStep + handoff 검수필요 + team add 와이어 (1.9.414)', run: () => { const m = require('../lib/pure-utils'); const on = m._composeTeamPlan({ id: 't', members: ['a', 'b'], personas: ['security'] }, '점검'); const off = m._composeTeamPlan({ id: 't', members: ['a'], review: false }, '점검'); const planOk = on.review === true && !!on.reviewStep && on.reviewStep.suggestedCommand.includes('verify-claim') && off.review === false && !off.reviewStep; const rem = m._teamHandoffReminders([{ id: 'r', schedule: 'every-session', status: 'active', members: ['a'], review: true }]); const remOk = rem.length === 1 && rem[0].includes('검수필요'); const teamSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'team.js')); const wired = teamSrc.includes("review: !has('--no-review')") && teamSrc.includes('메인 검수 (필수)'); return planOk && remOk && wired; } },
|
|
@@ -6264,7 +6358,7 @@ function _selfTestCases() {
|
|
|
6264
6358
|
return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
|
|
6265
6359
|
&& _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
|
|
6266
6360
|
&& _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk() && _p0104UsageOk()
|
|
6267
|
-
&& _p0014SecretBaselineOk() && _p0104ReminderOk();
|
|
6361
|
+
&& _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk();
|
|
6268
6362
|
} },
|
|
6269
6363
|
{ name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
|
|
6270
6364
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
|
|
@@ -7892,6 +7986,48 @@ function _p0103RootOk() {
|
|
|
7892
7986
|
return wroteTarget && bareIsCwd && pageBare && pageWithPath && libTarget && typoRejected && typoTech;
|
|
7893
7987
|
} catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
|
|
7894
7988
|
}
|
|
7989
|
+
// 1.36.105 (P-0015, 사용자 승인): 운영 등급 — 등급이 '이름' 이 아니라 '읽히는 양' 을 바꾸는가.
|
|
7990
|
+
// 대조군 없이는 무의미하다: (a) 기존 사용자(등급 미지정)는 종전과 동일해야 하고,
|
|
7991
|
+
// (b) 최소 등급에서도 핵심 3종(handoff·verify-claim·session close)은 살아 있어야 한다.
|
|
7992
|
+
// 그 둘이 깨지면 이건 개선이 아니라 기능 제거다.
|
|
7993
|
+
// selftest 는 **가볍게** 유지한다 — 첫 판에서 여기에 init 4회+spawn 15회를 넣었더니 selftest 가 느려져
|
|
7994
|
+
// doctor(선테스트 내장)와 '위치독립' e2e 두 건이 타임아웃으로 터졌다. 행위 전수는 e2e 블록 G 가 맡고,
|
|
7995
|
+
// 여기서는 **순수 호출**로 계약의 핵심만 본다(스폰 0). 공허해지지 않게 대조군을 함께 둔다.
|
|
7996
|
+
function _p0015ModeOk() {
|
|
7997
|
+
try {
|
|
7998
|
+
// ① 등급 해석 — 알 수 없는 값은 standard 로 (기존 프로젝트 무변경이 승인 조건이었다)
|
|
7999
|
+
// `strict` 는 1.36.105 에서 **뺐다** — 생성물이 standard 와 바이트 동일인데 "독립 검수 요구" 라고
|
|
8000
|
+
// 안내하고 있었다(검수가 지적). 없는 등급을 광고하지 않는다. 그래서 여기서도 standard 로 떨어져야 한다.
|
|
8001
|
+
const normOk = _normMode('minimal') === 'minimal' && _normMode('standard') === 'standard'
|
|
8002
|
+
&& _normMode('') === 'standard' && _normMode('MINIMAL') === 'minimal'
|
|
8003
|
+
&& _normMode('banana') === 'standard' && _normMode(null) === 'standard'
|
|
8004
|
+
&& _normMode('strict') === 'standard' && _MODES.length === 2;
|
|
8005
|
+
// ② 생성물이 실제로 갈린다 — 이름만 바뀌면 이 기능은 무의미하다
|
|
8006
|
+
const full = coreFiles('.', 'ko', [], {});
|
|
8007
|
+
const min = coreFiles('.', 'ko', [], { mode: 'minimal' });
|
|
8008
|
+
const std = coreFiles('.', 'ko', [], { mode: 'standard' });
|
|
8009
|
+
const shrinks = min['AGENTS.md'].length < full['AGENTS.md'].length / 5
|
|
8010
|
+
&& min['CLAUDE.md'].length < full['CLAUDE.md'].length / 5;
|
|
8011
|
+
const stdUnchanged = std['AGENTS.md'] === full['AGENTS.md'] && std['CLAUDE.md'] === full['CLAUDE.md']; // 대조군
|
|
8012
|
+
// ③ 최소 등급에서도 핵심 3종은 지침에 남는다 (덜어내는 건 주변이지 핵심이 아니다)
|
|
8013
|
+
const ag = min['AGENTS.md'];
|
|
8014
|
+
const coreKept = /leerness handoff/.test(ag) && /verify-claim/.test(ag) && /session close/.test(ag);
|
|
8015
|
+
// ④ 매니페스트에 등급이 실제로 기록된다
|
|
8016
|
+
let manifestOk = false;
|
|
8017
|
+
try { manifestOk = JSON.parse(min['.harness/manifest.json']).mode === 'minimal'
|
|
8018
|
+
&& JSON.parse(full['.harness/manifest.json']).mode === 'standard'; } catch { manifestOk = false; }
|
|
8019
|
+
// ⑤ 이월은 **전량 보존**이다 — 1.36.105 에서 상한을 넣었다가 뺐다(검수가 "앞에 붙인 최신 지시가 삭제되고
|
|
8020
|
+
// 보관본에도 없다" 를 재현). 이 파일의 계약은 false-DROP 이 버그이므로, 어떤 옵션으로도 줄지 않아야 한다.
|
|
8021
|
+
const pu = require('../lib/pure-utils');
|
|
8022
|
+
const nextT = '# T\ncanon\n';
|
|
8023
|
+
const prevT = '# T\ncanon\n' + Array.from({ length: 50 }, (_, i) => `- custom line ${i}`).join('\n') + '\n';
|
|
8024
|
+
const merged = pu._managedMerge('CLAUDE.md', nextT, prevT, '.harness/archive', null, {});
|
|
8025
|
+
const withOpt = pu._managedMerge('CLAUDE.md', nextT, prevT, '.harness/archive', null, { preservedCap: 10 });
|
|
8026
|
+
const noDrop = merged.includes('custom line 0') && merged.includes('custom line 49')
|
|
8027
|
+
&& withOpt === merged; // 상한 옵션이 남아 있어도 아무것도 줄이지 않는다(되돌림이 확실한가)
|
|
8028
|
+
return normOk && shrinks && stdUnchanged && coreKept && manifestOk && noDrop;
|
|
8029
|
+
} catch { return false; }
|
|
8030
|
+
}
|
|
7895
8031
|
// 1.36.104: handoff 가 사람이 쓴 .harness/agent-reminders.md 를 통째로 지웠다(exit 0 · 안내 없음 · 백업 없음).
|
|
7896
8032
|
// 대조군이 필수다 — 자동 생성분까지 남기면 그 파일이 영구 잔존해 매 세션 거짓 경보가 된다.
|
|
7897
8033
|
function _p0104ReminderOk() {
|
|
@@ -13673,7 +13809,24 @@ function handoff(root) {
|
|
|
13673
13809
|
for (const r of activeRules) log(`- ${r.id} [${r.trigger}] ${r.rule} (lastVerified: ${r.lastVerified || '-'})`);
|
|
13674
13810
|
log('');
|
|
13675
13811
|
}
|
|
13676
|
-
|
|
13812
|
+
// 1.36.105 (P-0015): 최소 등급은 문서 본문 블록(Current State/Plan/Progress/Decisions/Task Log)을 붙이지 않는다.
|
|
13813
|
+
// 실측 — 신규 프로젝트에서도 이 블록들이 handoff 출력의 대부분이고, 오래된 프로젝트에서는 18KB 까지 자란다.
|
|
13814
|
+
// 대신 어디를 읽으면 되는지만 알린다. 헤드라인·활성 룰·경고는 등급과 무관하게 유지한다(끄면 안 되는 신호).
|
|
13815
|
+
// 1.36.105 (P-0009 B): `full-reread` 토글도 같은 지점을 끈다 — 등급과 독립적으로 사용자가 직접 고를 수 있다.
|
|
13816
|
+
// 문서와 상태는 그대로 남는다. 바뀌는 것은 "매 세션 전부 실어 준다" → "필요할 때 읽어라" 뿐이다.
|
|
13817
|
+
// ⚠ return 하지 않는다. 처음엔 여기서 return 했는데, 그러면 `full-reread` 하나가 뒤따르는 워크플로 안내까지
|
|
13818
|
+
// 함께 꺼서 `workflow-distribute` 의 영역을 침범했다(토글을 격리해서 시험하니 드러났다 — 한꺼번에 끄면
|
|
13819
|
+
// 앞 토글의 return 이 뒤 토글을 가려 배선을 통째로 떼도 통과한다). 토글은 자기 것만 꺼야 한다.
|
|
13820
|
+
if (_projectMode(root) === 'minimal' || !_tgl.toggleOn(root, 'full-reread')) {
|
|
13821
|
+
const _en = _uiLang(root) === 'en';
|
|
13822
|
+
log(_en
|
|
13823
|
+
? `(document bodies omitted. Read on demand: .harness/current-state.md · plan.md · progress-tracker.md · decisions.md)`
|
|
13824
|
+
: `(문서 본문 생략. 필요할 때 읽으세요: .harness/current-state.md · plan.md · progress-tracker.md · decisions.md)`);
|
|
13825
|
+
log(_en ? `Restore: leerness toggle set full-reread on · tier: leerness mode set standard`
|
|
13826
|
+
: `되돌리기: leerness toggle set full-reread on · 등급: leerness mode set standard`);
|
|
13827
|
+
} else {
|
|
13828
|
+
log(out);
|
|
13829
|
+
}
|
|
13677
13830
|
if (exists(currentStatePath(root))) {
|
|
13678
13831
|
const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
13679
13832
|
writeUtf8(currentStatePath(root), cs);
|
|
@@ -13838,6 +13991,10 @@ function handoff(root) {
|
|
|
13838
13991
|
// "다음 단계로 무엇을 할지" 3개를 즉시 제안 (사용자가 직접 채우지 않아도 AI에게 노출됨)
|
|
13839
13992
|
// skill installed 여부와 무관 — keyword 만 있으면 동작 (게으름 방지는 모든 워크스페이스 필수)
|
|
13840
13993
|
// 끄기: --no-next-actions 또는 LEERNESS_NO_NEXT_ACTIONS=1
|
|
13994
|
+
// 1.36.105 (P-0009 B): `double-verify` 토글 — "완료 전에 한 번 더 확인하라" 류 **안내**를 끈다.
|
|
13995
|
+
// codex 검수 MEDIUM#9: 처음엔 이 블록 전체를 토글로 감쌌는데, 그 안에 next-action **큐 저장**이 있어
|
|
13996
|
+
// 토글을 끄면 지속 상태(next-action-queue.json)까지 사라졌다 — 내가 세운 계약("지시문만 끈다") 위반이다.
|
|
13997
|
+
// 큐 저장은 도구가 실제로 남기는 상태이므로 유지하고, 출력만 토글에 건다.
|
|
13841
13998
|
if (!has('--no-next-actions') && !has('--quiet') && process.env.LEERNESS_NO_NEXT_ACTIONS !== '1') {
|
|
13842
13999
|
try {
|
|
13843
14000
|
const actions = _suggestNextActions(root, latestRow, keyword);
|
|
@@ -13845,18 +14002,20 @@ function handoff(root) {
|
|
|
13845
14002
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
13846
14003
|
const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
13847
14004
|
const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
13848
|
-
|
|
13849
|
-
|
|
13850
|
-
log(
|
|
13851
|
-
|
|
14005
|
+
const _showAdvice = _tgl.toggleOn(root, 'double-verify');
|
|
14006
|
+
if (_showAdvice) {
|
|
14007
|
+
log(grn(`## 🎯 다음 단계 자동 제안 (1.9.194 E축 — 게으름 방지) — 키워드 "${keyword}"`));
|
|
14008
|
+
for (const a of actions) {
|
|
14009
|
+
log(dim(` ${a.icon} ${a.title}`));
|
|
14010
|
+
if (a.command) log(dim(` \`${a.command}\``));
|
|
14011
|
+
}
|
|
13852
14012
|
}
|
|
13853
|
-
// 1.9.201: queue 자동 저장 — `leerness next-action take` 로 즉시 task add 가능
|
|
14013
|
+
// 1.9.201: queue 자동 저장 — `leerness next-action take` 로 즉시 task add 가능 (토글과 무관: 상태다)
|
|
13854
14014
|
try {
|
|
13855
14015
|
const added = _enqueueNextActions(root, actions);
|
|
13856
|
-
if (added > 0) log(dim(` → 즉시 task add: leerness next-action take (큐 +${added}건 저장됨, 1.9.201)`));
|
|
14016
|
+
if (added > 0 && _showAdvice) log(dim(` → 즉시 task add: leerness next-action take (큐 +${added}건 저장됨, 1.9.201)`));
|
|
13857
14017
|
} catch {}
|
|
13858
|
-
log(dim(` → 직접 입력: leerness next-action add "<text>"`));
|
|
13859
|
-
log('');
|
|
14018
|
+
if (_showAdvice) { log(dim(` → 직접 입력: leerness next-action add "<text>"`)); log(''); }
|
|
13860
14019
|
}
|
|
13861
14020
|
} catch {}
|
|
13862
14021
|
}
|
|
@@ -14335,7 +14494,11 @@ function handoff(root) {
|
|
|
14335
14494
|
} catch {}
|
|
14336
14495
|
}
|
|
14337
14496
|
// 1.9.39: handoff 출력 끝에 6단계 워크플로 가이드 자동 표시 (메인 에이전트가 매 세션 인지)
|
|
14338
|
-
|
|
14497
|
+
// 1.36.105 (P-0009 B, 사용자 승인): `workflow-distribute` 토글로도 끌 수 있다(기본 ON = 현행 무변경).
|
|
14498
|
+
// 이건 **지시문**이다 — 끄더라도 gate/verify-claim/scan/audit 이 실제로 실행해 남기는 증거는 그대로다.
|
|
14499
|
+
// minimal 등급은 적재 총량을 줄이는 것이 목적이라 안내도 싣지 않는다(토글과 별개 축).
|
|
14500
|
+
if (!has('--no-workflow-guide') && !has('--compact') && process.env.LEERNESS_NO_WORKFLOW_GUIDE !== '1'
|
|
14501
|
+
&& _projectMode(root) !== 'minimal' && _tgl.toggleOn(root, 'workflow-distribute')) {
|
|
14339
14502
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
14340
14503
|
const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
14341
14504
|
const b = s => isTty ? `\x1b[1m${s}\x1b[0m` : s;
|
|
@@ -25605,9 +25768,127 @@ async function main() {
|
|
|
25605
25768
|
} catch {}
|
|
25606
25769
|
}
|
|
25607
25770
|
// 1.9.276 (GPT-5.5 2차 리뷰): init --dry-run(미리보기) / --minimal(핵심 파일만) / --no-env(.env 생략)
|
|
25771
|
+
// 1.36.105 (P-0015): 운영 등급 조회/변경 + 세션 적재 예산.
|
|
25772
|
+
if (cmd === 'mode') {
|
|
25773
|
+
const _tty = process.stdout && process.stdout.isTTY;
|
|
25774
|
+
const _cy = s => _tty ? `\x1b[36m${s}\x1b[0m` : s, _dm = s => _tty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
25775
|
+
const _mRoot = absRoot(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
|
|
25776
|
+
const _mj = has('--json');
|
|
25777
|
+
if (!exists(path.join(_mRoot, '.harness'))) { failJson(_mj, 'harness_missing', `leerness 미설치: ${_mRoot} — 먼저 leerness init`); return; }
|
|
25778
|
+
const sub = args[1] && !args[1].startsWith('-') && !_MODES.includes(args[1]) && !/^[.\/\\]|^[A-Za-z]:/.test(args[1]) ? args[1] : (_MODES.includes(args[1]) ? 'set' : 'get');
|
|
25779
|
+
const cur = _projectMode(_mRoot);
|
|
25780
|
+
if (sub === 'get') {
|
|
25781
|
+
if (_mj) { log(JSON.stringify({ ok: true, root: _mRoot, mode: cur, modes: _MODES }, null, 2)); return; }
|
|
25782
|
+
log(_cy(`# leerness mode — ${cur}`));
|
|
25783
|
+
log(` minimal 핵심 3종만 읽힌다 (handoff · verify-claim · session close)`);
|
|
25784
|
+
log(` standard 현재 기본 — 전체 지침/상태 문서`);
|
|
25785
|
+
// 목록은 _MODES 에서 유도한다 — 하드코딩하면 등급을 뺐을 때 안내만 남아 없는 등급을 계속 광고한다
|
|
25786
|
+
// (실제로 strict 제거 후 이 줄만 살아남아 게이트가 잡았다).
|
|
25787
|
+
log(_dm(` 변경: leerness mode set <${_MODES.join('|')}> · 적재량: leerness context budget`));
|
|
25788
|
+
return;
|
|
25789
|
+
}
|
|
25790
|
+
if (sub === 'set') {
|
|
25791
|
+
const want = _normMode(args.find(a => _MODES.includes(a)));
|
|
25792
|
+
if (!args.some(a => _MODES.includes(a))) { failJson(_mj, 'invalid_mode', `mode 는 ${_MODES.join('|')} 중 하나입니다`); return; }
|
|
25793
|
+
const mf = path.join(_mRoot, '.harness', 'manifest.json');
|
|
25794
|
+
let m = {}; try { m = JSON.parse(read(mf)) || {}; } catch { m = {}; }
|
|
25795
|
+
m.mode = want; writeUtf8(mf, JSON.stringify(m, null, 2) + '\n');
|
|
25796
|
+
// 지침 파일은 등급의 산출물이므로 즉시 재생성한다 — 안 그러면 mode 는 이름만 바뀌고 읽히는 양은 그대로다.
|
|
25797
|
+
let regenerated = 0;
|
|
25798
|
+
try {
|
|
25799
|
+
const _l = _uiLang(_mRoot);
|
|
25800
|
+
const gen = coreFiles(_mRoot, _l, [], { mode: want });
|
|
25801
|
+
let prevGen = null;
|
|
25802
|
+
try { prevGen = coreFiles(_mRoot, _l, [], { mode: cur }); } catch { prevGen = null; } // 직전 등급 템플릿(차감용)
|
|
25803
|
+
for (const k of ['AGENTS.md', 'CLAUDE.md']) {
|
|
25804
|
+
if (!gen[k]) continue;
|
|
25805
|
+
const p = path.join(_mRoot, k);
|
|
25806
|
+
const prev = exists(p) ? read(p) : '';
|
|
25807
|
+
// 1.36.105 (codex 검수 HIGH#5/#6): 처음엔 `\n---\n## Preserved...` 를 indexOf 로 잘라 붙였다.
|
|
25808
|
+
// 그 형태는 두 방향으로 사용자 내용을 지웠다 — (a) 그 블록 밖에 덧붙인 줄은 통째로 사라지고,
|
|
25809
|
+
// (b) CRLF 파일은 앵커가 아예 안 잡혀 Preserved 블록까지 사라졌다(검수가 둘 다 재현했다).
|
|
25810
|
+
// 등급 변경은 템플릿 교체이지 사용자 데이터 편집이 아니다 — 이미 검증된 병합 기제를 쓴다.
|
|
25811
|
+
// altTemplate 에는 **직전 등급의 템플릿**을 준다. 안 주면 옛 템플릿이 "사용자 커스텀" 으로 오인돼
|
|
25812
|
+
// Preserved 에 통째 이월되고, `mode set minimal` 이 파일을 줄이는 대신 키운다(실측 7,627 → 8,392B).
|
|
25813
|
+
// 이 차감은 1.36.60 이 언어 전환용으로 만든 것과 같은 기제다 — 축만 다르다.
|
|
25814
|
+
writeUtf8(p, managedMerge(k, gen[k], prev, path.join(_mRoot, '.harness', 'archive'),
|
|
25815
|
+
{ lang: _l, root: _mRoot, altTemplate: (prevGen && prevGen[k]) || '' }));
|
|
25816
|
+
regenerated++;
|
|
25817
|
+
}
|
|
25818
|
+
} catch {}
|
|
25819
|
+
if (_mj) { log(JSON.stringify({ ok: true, root: _mRoot, mode: want, previous: cur, regenerated }, null, 2)); return; }
|
|
25820
|
+
ok(`운영 등급: ${cur} → ${want} (지침 ${regenerated}개 재생성)`);
|
|
25821
|
+
log(_dm(` 적재량 확인: leerness context budget`));
|
|
25822
|
+
return;
|
|
25823
|
+
}
|
|
25824
|
+
failJson(_mj, 'unknown_subcommand', `알 수 없는 mode 하위명령: ${sub} (가능: get, set)`);
|
|
25825
|
+
return;
|
|
25826
|
+
}
|
|
25827
|
+
if (cmd === 'context' && args[1] === 'budget') {
|
|
25828
|
+
const _tty2 = process.stdout && process.stdout.isTTY;
|
|
25829
|
+
const _cy = s => _tty2 ? `\x1b[36m${s}\x1b[0m` : s, _dm = s => _tty2 ? `\x1b[2m${s}\x1b[0m` : s;
|
|
25830
|
+
const _bRoot = absRoot(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd());
|
|
25831
|
+
const _bj = has('--json');
|
|
25832
|
+
if (!exists(path.join(_bRoot, '.harness'))) { failJson(_bj, 'harness_missing', `leerness 미설치: ${_bRoot} — 먼저 leerness init`); return; }
|
|
25833
|
+
const _tok = (s) => Math.round(String(s || '').length / 3.2); // 한글 혼합 대략치 — 절대값이 아니라 추세/비교용
|
|
25834
|
+
const parts = [];
|
|
25835
|
+
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
|
|
25836
|
+
const p = path.join(_bRoot, f);
|
|
25837
|
+
parts.push({ what: f, bytes: exists(p) ? fs.statSync(p).size : 0, tokens: exists(p) ? _tok(read(p)) : 0 });
|
|
25838
|
+
}
|
|
25839
|
+
// 1.36.105 (codex 검수 HIGH#7): AGENTS.md 가 "읽어라" 고 지목하는 .harness 문서를 빼고 재면 예산이 거짓이 된다
|
|
25840
|
+
// (검수 재현: session-workflow.md 에 100,000자를 넣어도 ok:true). 지목된 문서를 합산에 넣는다.
|
|
25841
|
+
try {
|
|
25842
|
+
const agp = path.join(_bRoot, 'AGENTS.md');
|
|
25843
|
+
const refs = exists(agp) ? [...new Set((read(agp).match(/\.harness\/[A-Za-z0-9._-]+\.md/g) || []))] : [];
|
|
25844
|
+
let rb = 0, rt = 0, rn = 0;
|
|
25845
|
+
for (const r of refs) {
|
|
25846
|
+
const p = path.join(_bRoot, r.replace(/\//g, path.sep));
|
|
25847
|
+
if (!exists(p)) continue;
|
|
25848
|
+
rn++; rb += fs.statSync(p).size; rt += _tok(read(p));
|
|
25849
|
+
}
|
|
25850
|
+
if (rn) parts.push({ what: `AGENTS.md 가 지목하는 문서 ${rn}종`, bytes: rb, tokens: rt });
|
|
25851
|
+
} catch {}
|
|
25852
|
+
// spawnChild 는 자기 스코프의 root 를 cwd 로 쓰므로 여기서는 직접 부른다(측정 대상이 빈 문자열이 되면 예산이 무의미해진다).
|
|
25853
|
+
let ho = '';
|
|
25854
|
+
try {
|
|
25855
|
+
// 측정은 읽기여야 한다 — `--no-record` + LEERNESS_HOOK=1 로 stamp/프로필/그래프 쓰기를 전부 막는다.
|
|
25856
|
+
// (이 옵션들이 이미 있었는데 처음엔 쓰지 않아, budget 한 번이 last-handoff.json 과 tech-profile.json 을 바꿨다.)
|
|
25857
|
+
const r = cp.spawnSync(process.execPath, [__filename, 'handoff', _bRoot, '--no-drift-check', '--no-record'], {
|
|
25858
|
+
cwd: _bRoot, encoding: 'utf8', timeout: 180000, maxBuffer: 32 * 1024 * 1024,
|
|
25859
|
+
env: Object.assign({}, process.env, { LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_HOOK: '1', LEERNESS_NO_AUTO_ROADMAP: '1' })
|
|
25860
|
+
});
|
|
25861
|
+
ho = (r && r.stdout) || '';
|
|
25862
|
+
} catch {}
|
|
25863
|
+
if (!ho) { log(_dm(' ⚠ handoff 출력을 측정하지 못했습니다 — 합계는 지침 파일만 반영합니다')); }
|
|
25864
|
+
parts.push({ what: 'handoff 출력', bytes: Buffer.byteLength(ho), tokens: _tok(ho) });
|
|
25865
|
+
const total = parts.reduce((s, p) => s + p.tokens, 0);
|
|
25866
|
+
const mode = _projectMode(_bRoot);
|
|
25867
|
+
// 예산은 **실측에서** 정한다. 지목 문서(11종 4,133 tok)를 합산에 넣자 신규 standard 설치가 6,933 tok 이 됐다 —
|
|
25868
|
+
// 기본 설치가 자기 예산을 즉시 못 지키면 그건 신호가 아니라 잡음이다(이번 라운드에 시크릿에서 고친 오경보와 같은 형태).
|
|
25869
|
+
// 기준점: 신규 standard 6,933 · 427라운드 프로젝트 16,341(지목 문서 제외 값이라 실제로는 더 크다).
|
|
25870
|
+
// 12,000 은 신규 설치에 약 40% 여유를 주면서 오래 자란 프로젝트는 확실히 잡는 선이다.
|
|
25871
|
+
const budget = { minimal: 1200, standard: 12000 }[mode] || 12000;
|
|
25872
|
+
const over = total > budget;
|
|
25873
|
+
if (_bj) { log(JSON.stringify({ ok: !over, root: _bRoot, mode, budget, total, over, parts }, null, 2)); if (over) process.exitCode = 1; return; }
|
|
25874
|
+
log(_cy(`# leerness context budget — mode ${mode}`));
|
|
25875
|
+
for (const p of parts) log(` ${String(p.tokens).padStart(6)} tok ${String(p.bytes).padStart(7)} B ${p.what}`);
|
|
25876
|
+
log(` ${String(total).padStart(6)} tok 합계 · 예산 ${budget} tok`);
|
|
25877
|
+
log('');
|
|
25878
|
+
if (over) {
|
|
25879
|
+
fail(`세션 적재가 예산을 넘습니다 (${total} > ${budget})`);
|
|
25880
|
+
log(_dm(` → 등급을 낮추거나: leerness mode set minimal`));
|
|
25881
|
+
log(_dm(` → 이월 이력을 줄이거나: AGENTS.md/CLAUDE.md 의 "Preserved previous content" 를 archive 로 옮기세요`));
|
|
25882
|
+
} else {
|
|
25883
|
+
ok(`예산 이내 (${total}/${budget} tok)`);
|
|
25884
|
+
}
|
|
25885
|
+
log(_dm(' ⓘ 토큰은 길이 기반 추정치입니다 — 절대값이 아니라 추세/비교용으로 쓰세요.'));
|
|
25886
|
+
return;
|
|
25887
|
+
}
|
|
25608
25888
|
if (cmd === 'init') {
|
|
25609
25889
|
const _initRoot = absRoot(arg('--path', args[1] && !args[1].startsWith('-') ? args[1] : process.cwd()));
|
|
25610
|
-
|
|
25890
|
+
// 1.36.105 (P-0015): --mode 는 '무엇을 읽히나' 를 정한다. --minimal(파일 수)과 직교하며 함께 쓸 수 있다.
|
|
25891
|
+
const _initOpts = { force:has('--force'), dry:has('--dry-run'), migration:false, minimal:has('--minimal'), noEnv:has('--no-env'), mode:_normMode(arg('--mode', null)) };
|
|
25611
25892
|
if (has('--json')) {
|
|
25612
25893
|
// 1.10.2 (UR-0146): init --json — 사람용 출력 묵음(setQuiet) + 순수 JSON 요약 1개. 기존엔 --json 을 silent ignore(배너만 출력).
|
|
25613
25894
|
// 1.10.3 (UR-0173): json:true → 배너/진행바 억제(TTY 누출 차단), nonInteractive:true → 대화 메뉴 차단(머신 계약).
|
|
@@ -25664,7 +25945,11 @@ async function main() {
|
|
|
25664
25945
|
if (cmd === 'lazy' && args[1] === 'detect') return lazyDetect(_resolveRoot(args[2]), { json: has('--json') });
|
|
25665
25946
|
if (cmd === 'memory' && args[1] === 'search') return memorySearch(arg('--path', process.cwd()), args.slice(2).join(' '));
|
|
25666
25947
|
if (cmd === 'hook' && args[1] === 'session-start') return hookSessionStartCmd(arg('--path', args[2] || process.cwd())); // 1.36.22: SessionStart 컨텍스트 주입(쓰기 0)
|
|
25667
|
-
|
|
25948
|
+
// 1.36.105: 읽기전용 호출(LEERNESS_HOOK=1)은 부수 쓰기를 하지 않는다 — 1.36.22 가 last-handoff stamp 에 세운 규약을
|
|
25949
|
+
// 같은 진입점의 나머지 쓰기(tech-profile 갱신 · 그래프 재생성)에도 적용한다. `context budget` 이 측정을 위해
|
|
25950
|
+
// handoff 를 자식으로 부르는데, 그것만으로 last-handoff.json 과 tech-profile.json 이 바뀌었다(자체 적대 검사로 발견).
|
|
25951
|
+
// 측정이 관측 대상을 바꾸면 안 된다.
|
|
25952
|
+
if (cmd === 'handoff') { const _hp = arg('--path', args[1] || process.cwd()); const _hr = handoffCmd(_hp); if (process.env.LEERNESS_HOOK !== '1') { try { _tech.refreshTechProfile(_hp); } catch {} _maybeAutoGraph(_hp); } return _hr; } // 1.36.53: 기술 프로필 자동 갱신(마이그레이션 이력) — 그래프 생성 전에
|
|
25668
25953
|
if (cmd === 'reuse-map') return reuseMapCmd(arg('--path', args[1] || process.cwd()));
|
|
25669
25954
|
if (cmd === 'verify-claim') { const _p = arg('--path', process.cwd()); if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p); return verifyClaimCmd(_p, args[1]); } // 1.33.2: --all → 모든 done 주장 일괄 검증
|
|
25670
25955
|
if (cmd === 'orchestrate') return await orchestrateCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')));
|
package/lib/diagnostics.js
CHANGED
|
@@ -27,11 +27,24 @@ function doctorCmd(opts = {}, deps = {}) {
|
|
|
27
27
|
let shell = null, psVersion = null;
|
|
28
28
|
try { const ctx = _detectShellCtx(); shell = ctx.shell; psVersion = ctx.psVersion; } catch {}
|
|
29
29
|
const mcpCount = (() => { try { return _mcpToolCount(); } catch { return null; } })();
|
|
30
|
+
// 1.36.106 (T-0090): selftest/doctor 가 임시 디렉토리를 흘려 왔다(실행당 8/3개). 새는 자리는 고쳤지만
|
|
31
|
+
// **이미 쌓인 것**은 사용자 디스크다 — 조용히 지우지 않고 사실만 알린다(삭제는 사용자 판단).
|
|
32
|
+
// 비용은 readdir 1회이고 stat 은 하지 않는다. 실패는 진단 실패가 아니므로 통째로 삼킨다.
|
|
33
|
+
// codex 검수 LOW#4/#5: 처음엔 readdir 이름만 봤다 — 그래서 (a) `leerness-note.txt` 같은 **파일**을
|
|
34
|
+
// "임시 디렉토리" 로 세고, (b) Windows(대소문자 무시 파일시스템)에서 `__LEERNESS_*` 를 놓쳤다.
|
|
35
|
+
// withFileTypes 로 디렉토리만 세고, 접두 비교는 대소문자를 무시한다.
|
|
36
|
+
let tmpLeftover = null;
|
|
37
|
+
try {
|
|
38
|
+
const tdir = require('os').tmpdir();
|
|
39
|
+
const ents = require('fs').readdirSync(tdir, { withFileTypes: true });
|
|
40
|
+
const n = ents.filter(e => e.isDirectory() && /^(__leerness_|leerness-)/i.test(e.name)).length;
|
|
41
|
+
if (n > 200) tmpLeftover = { dir: tdir, count: n };
|
|
42
|
+
} catch { tmpLeftover = null; }
|
|
30
43
|
const report = {
|
|
31
44
|
version: VERSION, node: process.version, platform: process.platform + '/' + process.arch,
|
|
32
45
|
runningFrom: harnessPath, mcpTools: mcpCount,
|
|
33
46
|
selftest: { pass, total, ok: failNames.length === 0, failed: failNames },
|
|
34
|
-
shell, psVersion, healthy: failNames.length === 0
|
|
47
|
+
shell, psVersion, tmpLeftover, healthy: failNames.length === 0
|
|
35
48
|
};
|
|
36
49
|
if (json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); if (!report.healthy) process.exitCode = 1; return report; }
|
|
37
50
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
@@ -45,6 +58,12 @@ function doctorCmd(opts = {}, deps = {}) {
|
|
|
45
58
|
log(` ${report.selftest.ok ? gr('✓') : rd('✗')} selftest: ${pass}/${total} ${t('통과', 'passed')}${report.selftest.ok ? '' : t(` (${total - pass}건 실패)`, ` (${total - pass} failed)`)}`);
|
|
46
59
|
if (!report.selftest.ok) report.selftest.failed.slice(0, 5).forEach(n => log(rd(` ✗ ${n}`)));
|
|
47
60
|
log(` ${gr('✓')} ${t('셸', 'shell')}: ${shell || 'unknown'}${psVersion && shell === 'powershell' ? ` (PowerShell ${psVersion})` : ''}`);
|
|
61
|
+
// 남은 임시 디렉토리는 '문제 감지'(healthy)로 올리지 않는다 — 동작에 지장이 없고, 과거 버전이 남긴 흔적이다.
|
|
62
|
+
// 지우는 명령을 대신 실행하지도 않는다: 경로를 알려주고 사용자가 판단한다.
|
|
63
|
+
if (tmpLeftover) {
|
|
64
|
+
log(` ${dm('ⓘ')} ${t(`임시 디렉토리 잔존 ${tmpLeftover.count}개 (1.36.106 이전 버전이 남긴 것 — 새 실행은 흘리지 않습니다)`, `${tmpLeftover.count} leftover temp dirs (created by versions before 1.36.106 — current runs leak none)`)}`);
|
|
65
|
+
log(` ${dm(tmpLeftover.dir)} ${dm(t('— 필요하면 직접 정리하세요(leerness 는 지우지 않습니다)', '— remove them yourself if you want (leerness will not)'))}`);
|
|
66
|
+
}
|
|
48
67
|
log('');
|
|
49
68
|
log(report.healthy ? gr(t(' ✓ leerness 설치 정상', ' ✓ leerness install OK')) : rd(t(' ✗ 문제 감지 — 재설치: npm i -g leerness@latest · 진단: leerness which', ' ✗ problem detected — reinstall: npm i -g leerness@latest · diagnose: leerness which')));
|
|
50
69
|
if (!report.healthy) process.exitCode = 1;
|
package/lib/pure-utils.js
CHANGED
|
@@ -1001,6 +1001,13 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
|
|
|
1001
1001
|
note += `\n> ⚠ ${hangulLines} Korean lines were preserved below. If this project never had Korean customizations, most of these are likely template text from an old Korean install — review and prune manually (data is also in the backup above). leerness will not auto-delete them: it cannot distinguish old template lines from your own notes with certainty.`;
|
|
1002
1002
|
}
|
|
1003
1003
|
}
|
|
1004
|
+
// 1.36.105 (P-0015): 여기에 이월 상한(preservedCap)을 넣었다가 **뺐다**.
|
|
1005
|
+
// 설계는 "삭제가 아니라 이동" 이었다 — 꼬리(최신)만 남기고 나머지는 위 note 가 가리키는 archive 백업에 맡긴다.
|
|
1006
|
+
// codex 검수가 전제를 깼다: 사용자가 새 지시를 **앞에** 붙이면 그게 최신인데 꼬리 규칙이 그걸 지우고,
|
|
1007
|
+
// 재현에서 보관본에도 남지 않았다. 즉 "이동" 이라는 주장이 성립하지 않는 입력이 있다.
|
|
1008
|
+
// 이 파일의 계약은 명시적으로 false-DROP 이 버그다(1.36.95 가 패턴 필터로 346줄을 지운 전례도 같은 자리다).
|
|
1009
|
+
// 안전을 증명할 수 없는 절감은 싣지 않는다 — 최소 등급의 절감은 AGENTS/CLAUDE/handoff 계층화로 이미 얻는다
|
|
1010
|
+
// (실측 2,800 → 284 tok). 오래된 이월을 줄이는 일은 별도 라운드에서 provenance 를 갖춰 다룬다.
|
|
1004
1011
|
return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\n${note}\n\n` + custom.join('\n') + '\n';
|
|
1005
1012
|
}
|
|
1006
1013
|
|
package/lib/toggles.js
CHANGED
|
@@ -18,7 +18,19 @@ const TOGGLE_REGISTRY = {
|
|
|
18
18
|
'bugfix-receipt': { desc: 'bugfix 완료 게이트 — `leerness bugfix start` 로 probe 를 등록한 task 에 한해, done 시 probe 통과 + 근본원인/형제범위 영수증 요구 (기본 OFF)', affects: 'task update --status done · task sync --from', defaultOff: true },
|
|
19
19
|
// 1.36.89 (P-0007): 켜도 **자동 실행은 없다** — `route --dispatch` 를 허용할 뿐이고, 고위험은 사람 승인이 별도로 필요하다.
|
|
20
20
|
'difficulty-routing': { desc: '작업 난이도별 모델 라우팅 — `leerness agents route` 의 --confirm 허용(제안/열람은 토글과 무관). 고위험은 --approved-by 필수 (기본 OFF)', affects: 'leerness agents route --confirm', defaultOff: true },
|
|
21
|
+
// 1.36.105 (P-0009 B, 사용자 승인): 검증 스캐폴딩 토글 3종. 기본 ON = 현행 무변경.
|
|
22
|
+
// 경계가 이 기능의 전부다 — 이 토글들이 끄는 것은 **AI 에게 또 시키는 지시문(스캐폴딩)** 뿐이고,
|
|
23
|
+
// 도구가 실제로 명령을 실행해 남기는 증거(gate · verify-claim · bugfix probe · scan)는 어떤 조합에서도 그대로다.
|
|
24
|
+
// leerness 는 "어느 모델에 무엇이 좋다" 를 주장하지 않는다 — 설명문에 모델명/성능 수치를 한 글자도 쓰지 않는다.
|
|
25
|
+
// 사용자가 자기 관측으로 끄는 스위치다. (P-0009 이 모델 자동 인지를 실측 근거로 기각한 이유이기도 하다.)
|
|
26
|
+
'workflow-distribute': { desc: '세션 워크플로의 "분배(sub-agent)" 단계 지시문 — 끄면 단계 안내를 싣지 않는다 (증거 검증에는 영향 없음)', affects: 'handoff · session-workflow 안내' },
|
|
27
|
+
'double-verify': { desc: '완료 전 재확인 지시문(같은 것을 다시 확인하라는 안내) — 끄면 안내만 생략된다 (gate/verify-claim 실행은 그대로)', affects: 'handoff · lens 안내' },
|
|
28
|
+
'full-reread': { desc: '매 세션 관련 문서 전체 재독 지시문 — 끄면 "필요할 때 읽어라" 로 바뀐다 (문서와 상태는 그대로 남는다)', affects: 'handoff 안내' },
|
|
21
29
|
};
|
|
30
|
+
// P-0009 경계 계약: 토글로 끌 수 있는 것은 '지시문' 뿐이다. 아래 목록은 **어떤 토글 조합에서도** 살아 있어야 하며,
|
|
31
|
+
// 변이 테스트가 이 계약을 고정한다(계약이 깨지면 토글을 출하하지 않는다는 것이 승인 조건이었다).
|
|
32
|
+
const SCAFFOLD_TOGGLES = ['workflow-distribute', 'double-verify', 'full-reread'];
|
|
33
|
+
const EVIDENCE_COMMANDS = ['gate', 'verify-claim', 'scan', 'audit'];
|
|
22
34
|
|
|
23
35
|
function _togglesPath(root) { return path.join(absRoot(root), '.harness', 'toggles.json'); }
|
|
24
36
|
|
|
@@ -117,4 +129,4 @@ function toggleCmd(root, sub, id, val, deps = {}) {
|
|
|
117
129
|
fail(`알 수 없는 하위명령: ${sub} (가능: list, set)`); process.exitCode = 1;
|
|
118
130
|
}
|
|
119
131
|
|
|
120
|
-
module.exports = { TOGGLE_REGISTRY, loadToggles, loadTogglesChecked, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
|
|
132
|
+
module.exports = { TOGGLE_REGISTRY, SCAFFOLD_TOGGLES, EVIDENCE_COMMANDS, loadToggles, loadTogglesChecked, 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.106",
|
|
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
|
@@ -3752,7 +3752,10 @@ total++;
|
|
|
3752
3752
|
// 전체 스위트 부하에서 간헐 초과했고, 실제로 게이트 한 번을 실패시켰다(내 변경 때문이 아니라 잠복 결함).
|
|
3753
3753
|
// 같은 명령의 다른 호출(4644·6955)은 1.36.87 에서 이미 120초로 올려 뒀다 — 여기만 빠져 있었다.
|
|
3754
3754
|
// 검사 대상은 속도가 아니라 동작이다.
|
|
3755
|
-
|
|
3755
|
+
// 1.36.105: 여유가 1.2배뿐이라 전체 부하에서 반복해 터진다(이번 세션 1회 · 1.36.100 에서 1회).
|
|
3756
|
+
// 측정 근거 — **게시된 1.36.104** 격리 실측 doctor 97s / selftest 99s, 작업 트리 101s/94s (동일 수준).
|
|
3757
|
+
// 즉 내 변경이 느리게 만든 게 아니라 제한이 처음부터 빠듯했다. 3배 여유로 올린다(검사 대상은 속도가 아니라 동작).
|
|
3758
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { encoding: 'utf8', timeout: 300000 });
|
|
3756
3759
|
let j = null; try { j = JSON.parse(r.stdout); } catch {}
|
|
3757
3760
|
const doctorOk = j && j.version && typeof j.mcpTools === 'number' && j.mcpTools >= 80 && j.selftest && j.selftest.total > 0 && j.healthy === true && r.status === 0;
|
|
3758
3761
|
// commands 요약 + banner 가 실제 MCP 수 노출 (하드코딩 65/46 아님)
|
|
@@ -4649,7 +4652,7 @@ total++;
|
|
|
4649
4652
|
const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 180000 });
|
|
4650
4653
|
const sout = (sr.stdout || '') + (sr.stderr || '');
|
|
4651
4654
|
const selftestOk = sr.status === 0 && /전체 \d+건 통과/.test(sout) && !/설치 손상/.test(sout);
|
|
4652
|
-
const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout:
|
|
4655
|
+
const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 300000 }); // 1.36.87: doctor 는 selftest 를 내장한다 · 1.36.105: 실측 97~101s(게시본 포함) 라 120s 는 여유 1.2배였다 → 300s
|
|
4653
4656
|
const dout = (dr.stdout || '') + (dr.stderr || '');
|
|
4654
4657
|
// 1.36.87 (codex 26차 #11): 종료코드를 안 보면 **타임아웃/중단이 통과로 보인다**(spawnSync 타임아웃은 status=null,
|
|
4655
4658
|
// 이미 찍힌 "통과" 문자열은 남는다). 제한을 120s 로 올린 만큼 status 단언이 반드시 함께 가야 한다.
|
|
@@ -9888,6 +9891,201 @@ total++;
|
|
|
9888
9891
|
if (!ok) failed++;
|
|
9889
9892
|
}
|
|
9890
9893
|
|
|
9894
|
+
// ── 1.36.105 (P-0015): 운영 등급 — 등급이 '이름' 이 아니라 '읽히는 양' 을 바꾸는가.
|
|
9895
|
+
// 실측 동기: init --minimal 은 디스크 파일만 줄이고 세션 적재는 2,800→2,790 tok(-0.3%) 였다.
|
|
9896
|
+
// 대조군 둘이 핵심이다 — 기존 사용자는 무변경이어야 하고, 최소 등급에서도 핵심 3종은 살아 있어야 한다.
|
|
9897
|
+
{
|
|
9898
|
+
total++;
|
|
9899
|
+
let ok = false; const dbg = {};
|
|
9900
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-md105-'));
|
|
9901
|
+
try {
|
|
9902
|
+
const mk = (tag, extra) => {
|
|
9903
|
+
const d = path.join(arena, tag); fs.mkdirSync(d, { recursive: true });
|
|
9904
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
9905
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', ...(extra || [])], { cwd: d, encoding: 'utf8', timeout: 180000 });
|
|
9906
|
+
return d;
|
|
9907
|
+
};
|
|
9908
|
+
const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
|
|
9909
|
+
const J = (d, a) => { try { return JSON.parse((((R(d, a)).stdout) || '').trim()); } catch { return null; } };
|
|
9910
|
+
// 대조군 ① 등급 미지정 = standard, 지침 크기 종전 그대로
|
|
9911
|
+
const std = mk('std');
|
|
9912
|
+
const sB = J(std, ['context', 'budget', '--json']);
|
|
9913
|
+
dbg.stdDefault = (J(std, ['mode', '--json']) || {}).mode === 'standard'
|
|
9914
|
+
&& fs.statSync(path.join(std, 'AGENTS.md')).size > 7000 && !!sB && sB.over === false;
|
|
9915
|
+
// 최소 등급은 실제로 줄인다
|
|
9916
|
+
const min = mk('min', ['--mode', 'minimal']);
|
|
9917
|
+
const mB = J(min, ['context', 'budget', '--json']);
|
|
9918
|
+
dbg.shrinks = !!mB && !!sB && mB.total < sB.total / 5;
|
|
9919
|
+
dbg.shortAgents = fs.statSync(path.join(min, 'AGENTS.md')).size < 1200;
|
|
9920
|
+
// minimal 은 문서 본문도 워크플로 안내도 싣지 않는다(적재 총량이 목적) — 그러면서 헤드라인은 남는다
|
|
9921
|
+
const minHo = (R(min, ['handoff', '.']).stdout) || '';
|
|
9922
|
+
dbg.minLean = !/=== Current State/.test(minHo) && !/세션 워크플로 6단계/.test(minHo)
|
|
9923
|
+
&& /문서 본문 생략/.test(minHo) && /헤드라인/.test(minHo);
|
|
9924
|
+
// 대조군 ② 최소 등급에서도 핵심 3종은 문서에 남고 실제로 동작한다 (기능 제거가 아님)
|
|
9925
|
+
const ag = fs.readFileSync(path.join(min, 'AGENTS.md'), 'utf8');
|
|
9926
|
+
dbg.coreDoc = /handoff/.test(ag) && /verify-claim/.test(ag) && /session close/.test(ag);
|
|
9927
|
+
R(min, ['task', 'add', 'sample']);
|
|
9928
|
+
dbg.coreRuns = R(min, ['handoff', '.']).status === 0 && R(min, ['session', 'close', '.']).status === 0;
|
|
9929
|
+
// 등급 전환이 산출물을 바꾸고 되돌아온다 (이름만 바뀌면 무의미)
|
|
9930
|
+
const sw = mk('sw');
|
|
9931
|
+
const b0 = fs.statSync(path.join(sw, 'AGENTS.md')).size;
|
|
9932
|
+
R(sw, ['mode', 'set', 'minimal']);
|
|
9933
|
+
const b1 = fs.statSync(path.join(sw, 'AGENTS.md')).size;
|
|
9934
|
+
R(sw, ['mode', 'set', 'standard']);
|
|
9935
|
+
const b2 = fs.statSync(path.join(sw, 'AGENTS.md')).size;
|
|
9936
|
+
dbg.switches = b1 < b0 / 3 && Math.abs(b2 - b0) < 200;
|
|
9937
|
+
// 등급 전환이 사용자 내용을 지우지 않는다 — LF/CRLF 둘 다 (검수가 두 경우 모두 삭제를 재현했다)
|
|
9938
|
+
const uc = mk('user');
|
|
9939
|
+
fs.appendFileSync(path.join(uc, 'AGENTS.md'), '\nDIRECT-USER-INSTRUCTION-DO-NOT-LOSE\n');
|
|
9940
|
+
R(uc, ['mode', 'set', 'minimal']);
|
|
9941
|
+
dbg.keepsUserLf = fs.readFileSync(path.join(uc, 'AGENTS.md'), 'utf8').includes('DIRECT-USER-INSTRUCTION-DO-NOT-LOSE');
|
|
9942
|
+
const cr = mk('crlf');
|
|
9943
|
+
const crp = path.join(cr, 'AGENTS.md');
|
|
9944
|
+
fs.writeFileSync(crp, fs.readFileSync(crp, 'utf8').replace(/\n/g, '\r\n') + '\r\n---\r\n## Preserved previous content\r\n\r\nCRLF-PRESERVED-USER-INSTRUCTION\r\n');
|
|
9945
|
+
R(cr, ['mode', 'set', 'minimal']);
|
|
9946
|
+
dbg.keepsUserCrlf = fs.readFileSync(crp, 'utf8').includes('CRLF-PRESERVED-USER-INSTRUCTION');
|
|
9947
|
+
// 이월은 전량 보존 — 상한을 넣었다가 뺐다(앞에 붙인 최신 지시가 삭제되고 보관본에도 없었다)
|
|
9948
|
+
const pu2 = require(path.join(__dirname, '..', 'lib', 'pure-utils.js'));
|
|
9949
|
+
const nx = '# T\ncanon\n';
|
|
9950
|
+
const pv = '# T\ncanon\nNEWEST-PREPENDED\n' + Array.from({ length: 40 }, (_, i) => `- old ${i}`).join('\n') + '\n';
|
|
9951
|
+
const mg = pu2._managedMerge('CLAUDE.md', nx, pv, '.harness/archive', null, { preservedCap: 5 });
|
|
9952
|
+
dbg.noPreservedDrop = mg.includes('NEWEST-PREPENDED') && mg.includes('- old 0') && mg.includes('- old 39');
|
|
9953
|
+
// strict 는 제거됐다 — 없는 등급을 광고하지 않는다
|
|
9954
|
+
dbg.noFakeTier = !/strict/.test((R(sw, ['mode']).stdout) || '') && (J(sw, ['mode', '--json']) || {}).modes.length === 2;
|
|
9955
|
+
// 측정은 읽기여야 한다 — budget 이 last-handoff stamp / tech-profile 을 바꾸면 세션 탐지가 오염된다
|
|
9956
|
+
// (자체 적대 검사로 실제 발견: budget 한 번이 두 파일을 바꿨다)
|
|
9957
|
+
const hSnap = (x) => { const m = {}; (function w(y, rel) { for (const e of fs.readdirSync(y, { withFileTypes: true })) { const p = path.join(y, e.name); const r = rel + '/' + e.name; if (e.isDirectory()) w(p, r); else if (!/environment\.json|cache\//.test(r)) { try { m[r] = fs.readFileSync(p, 'utf8'); } catch { m[r] = 'x'; } } } })(path.join(x, '.harness'), ''); return m; };
|
|
9958
|
+
const preB = hSnap(std); R(std, ['context', 'budget']); const postB = hSnap(std);
|
|
9959
|
+
dbg.budgetReadOnly = Object.keys({ ...preB, ...postB }).every(k => preB[k] === postB[k]);
|
|
9960
|
+
// 예산 초과는 조용히 넘어가지 않는다
|
|
9961
|
+
fs.appendFileSync(path.join(min, 'AGENTS.md'), '\n' + 'x'.repeat(20000) + '\n');
|
|
9962
|
+
const ov = J(min, ['context', 'budget', '--json']);
|
|
9963
|
+
dbg.overLoud = !!ov && ov.over === true && R(min, ['context', 'budget', '--json']).status !== 0;
|
|
9964
|
+
ok = dbg.stdDefault && dbg.shrinks && dbg.shortAgents && dbg.minLean && dbg.coreDoc && dbg.coreRuns
|
|
9965
|
+
&& dbg.switches && dbg.keepsUserLf && dbg.keepsUserCrlf && dbg.noPreservedDrop && dbg.noFakeTier
|
|
9966
|
+
&& dbg.budgetReadOnly && dbg.overLoud;
|
|
9967
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
|
|
9968
|
+
console.log(ok ? '✓ G(1.36.105/P-0015) 운영 등급: 기존 무변경(대조군) + minimal 적재 1/5 미만 + 핵심 3종 생존 + 전환 왕복 + 예산 초과 경고'
|
|
9969
|
+
: '✗ P-0015 운영 등급 실패 ' + JSON.stringify(dbg));
|
|
9970
|
+
if (!ok) failed++;
|
|
9971
|
+
}
|
|
9972
|
+
|
|
9973
|
+
// ── 1.36.105 (P-0009 B, 사용자 승인): 검증 스캐폴딩 토글 3종.
|
|
9974
|
+
// 승인 조건이 "경계 계약을 시험으로 고정" 이었다 — 토글이 끄는 것은 **지시문** 뿐이고,
|
|
9975
|
+
// 도구가 실제로 실행해 남기는 증거(scan/audit/gate/verify-claim/session close)는 어떤 조합에서도 살아야 한다.
|
|
9976
|
+
{
|
|
9977
|
+
total++;
|
|
9978
|
+
let ok = false; const dbg = {};
|
|
9979
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sc105-'));
|
|
9980
|
+
try {
|
|
9981
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
9982
|
+
const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 180000 });
|
|
9983
|
+
R(['init', d, '--yes']); R(['task', 'add', '리팩터링 작업']);
|
|
9984
|
+
const SC = ['workflow-distribute', 'double-verify', 'full-reread'];
|
|
9985
|
+
// 기본 ON = 현행 무변경
|
|
9986
|
+
const h0 = (R(['handoff', '.']).stdout) || '';
|
|
9987
|
+
dbg.defaultOn = /세션 워크플로 6단계/.test(h0) && /=== Current State/.test(h0);
|
|
9988
|
+
dbg.registered = (((R(['toggle', 'list']).stdout) || '').match(/🟢 ON\s+(workflow-distribute|double-verify|full-reread)/g) || []).length === 3;
|
|
9989
|
+
// 설명문에 모델명/성능 주장이 없다 (승인 조건 — leerness 는 아무것도 주장하지 않는다)
|
|
9990
|
+
const reg = require(path.join(__dirname, '..', 'lib', 'toggles.js')).TOGGLE_REGISTRY;
|
|
9991
|
+
const desc = SC.map(k => (reg[k] || {}).desc || '').join(' ');
|
|
9992
|
+
dbg.noModelClaim = !/claude|opus|sonnet|haiku|gpt|codex|gemini|qwen|grok/i.test(desc)
|
|
9993
|
+
&& !/\d+(\.\d+)?\s*(x|배)\b|빠름|faster|절약|saves?\b/i.test(desc);
|
|
9994
|
+
// 토글마다 **격리** 해서 본다 — 한꺼번에 끄면 full-reread 의 early-return 이 나머지를 가려
|
|
9995
|
+
// workflow-distribute 배선을 떼도 통과한다(변이 M7 이 실제로 생존해 드러난 함정).
|
|
9996
|
+
R(['toggle', 'set', 'workflow-distribute', 'off']);
|
|
9997
|
+
const hw = (R(['handoff', '.']).stdout) || '';
|
|
9998
|
+
dbg.isolWorkflow = !/세션 워크플로 6단계/.test(hw) && /=== Current State/.test(hw);
|
|
9999
|
+
R(['toggle', 'set', 'workflow-distribute', 'on']);
|
|
10000
|
+
R(['toggle', 'set', 'full-reread', 'off']);
|
|
10001
|
+
const hr = (R(['handoff', '.']).stdout) || '';
|
|
10002
|
+
dbg.isolReread = !/=== Current State/.test(hr) && /세션 워크플로 6단계/.test(hr);
|
|
10003
|
+
R(['toggle', 'set', 'full-reread', 'on']);
|
|
10004
|
+
// OFF(전부) → 지시문만 사라진다
|
|
10005
|
+
for (const id of SC) R(['toggle', 'set', id, 'off']);
|
|
10006
|
+
const h1 = (R(['handoff', '.']).stdout) || '';
|
|
10007
|
+
dbg.guidanceGone = !/세션 워크플로 6단계/.test(h1) && !/=== Current State/.test(h1);
|
|
10008
|
+
dbg.signalKept = /헤드라인/.test(h1) && /toggle set full-reread on/.test(h1);
|
|
10009
|
+
// 토글은 **지시문만** 끈다 — 지속 상태를 바꾸면 계약 위반이다(검수 MEDIUM#9: double-verify OFF 가
|
|
10010
|
+
// next-action 큐를 지웠다). 특정 파일에 기대면 그 파일이 안 생기는 픽스처에서 단언이 공허해지므로
|
|
10011
|
+
// (실측: 이 픽스처에선 큐가 0B/0B 라 판별력이 없었다) **.harness 전체를 스냅샷 대조**한다.
|
|
10012
|
+
const hs = (x) => { const m = {}; (function w(y, rel) { for (const e of fs.readdirSync(y, { withFileTypes: true })) { const p = path.join(y, e.name); const r = rel + '/' + e.name; if (e.isDirectory()) w(p, r); else if (!/environment\.json|last-handoff\.json|cache\/|toggles\.json|pre-wake-report\.json/.test(r)) { try { m[r] = fs.readFileSync(p, 'utf8'); } catch { m[r] = 'x'; } } } })(path.join(x, '.harness'), ''); return m; };
|
|
10013
|
+
R(['toggle', 'set', 'double-verify', 'on']); R(['handoff', '.']);
|
|
10014
|
+
const stOn = hs(d);
|
|
10015
|
+
R(['toggle', 'set', 'double-verify', 'off']); R(['handoff', '.']);
|
|
10016
|
+
const stOff = hs(d);
|
|
10017
|
+
dbg.stateUntouched = Object.keys({ ...stOn, ...stOff }).every(k => stOn[k] === stOff[k]);
|
|
10018
|
+
// 경계 계약 — 증거는 그대로다
|
|
10019
|
+
fs.writeFileSync(path.join(d, 'leak.js'), 'const K="sk-proj-BoundaryTestKey1234567890abcdefgh";\n');
|
|
10020
|
+
const sc = R(['scan', 'secrets', '--json']);
|
|
10021
|
+
let sj = null; try { sj = JSON.parse((sc.stdout || '').trim()); } catch {}
|
|
10022
|
+
const parses = (r) => { try { return !!JSON.parse(((r.stdout) || '').trim()); } catch { return false; } };
|
|
10023
|
+
dbg.evidenceAlive = !!sj && sj.unacknowledgedCount >= 1 && sc.status !== 0
|
|
10024
|
+
&& parses(R(['audit', '--json'])) && parses(R(['gate', '--json']))
|
|
10025
|
+
&& R(['session', 'close', '.']).status === 0;
|
|
10026
|
+
// 되돌리면 원상복귀
|
|
10027
|
+
for (const id of SC) R(['toggle', 'set', id, 'on']);
|
|
10028
|
+
const h2 = (R(['handoff', '.']).stdout) || '';
|
|
10029
|
+
dbg.restores = /세션 워크플로 6단계/.test(h2) && /=== Current State/.test(h2);
|
|
10030
|
+
ok = dbg.defaultOn && dbg.registered && dbg.noModelClaim && dbg.isolWorkflow && dbg.isolReread
|
|
10031
|
+
&& dbg.stateUntouched && dbg.guidanceGone && dbg.signalKept && dbg.evidenceAlive && dbg.restores;
|
|
10032
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
|
|
10033
|
+
console.log(ok ? '✓ H(1.36.105/P-0009B) 스캐폴딩 토글: 기본 무변경 + 지시문만 OFF + 증거 계층 생존(경계 계약) + 모델명 0 + 복귀'
|
|
10034
|
+
: '✗ P-0009B 스캐폴딩 토글 실패 ' + JSON.stringify(dbg));
|
|
10035
|
+
if (!ok) failed++;
|
|
10036
|
+
}
|
|
10037
|
+
|
|
10038
|
+
// ── 1.36.106 (T-0090): selftest/doctor 가 임시 디렉토리를 흘렸다 — 실행당 8개/3개, 실측 누적 83,135개.
|
|
10039
|
+
// selftest 는 "CI 친화" 로 광고되는 명령이라 커밋마다 쌓인다. 정적으로는 안 보이고 **실행 전후 증가분**으로만 잡힌다.
|
|
10040
|
+
// 대조군: 사용자 명령(handoff/audit)은 원래 0이어야 하고, 여기서도 0이어야 한다(회귀 감시).
|
|
10041
|
+
{
|
|
10042
|
+
total++;
|
|
10043
|
+
let ok = false; const dbg = {};
|
|
10044
|
+
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-tl106-'));
|
|
10045
|
+
// 자식의 TMPDIR 를 격리한다 — 이 블록은 임시 디렉토리를 세므로 전체 스위트의 다른 블록이 만드는
|
|
10046
|
+
// 임시 디렉토리에 오염되면 판정이 흔들린다(그리고 파괴적 변이 시험이 실제 tmpdir 을 지우는 것도 막는다).
|
|
10047
|
+
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-tlbox-'));
|
|
10048
|
+
try {
|
|
10049
|
+
fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
10050
|
+
const ENV = Object.assign({}, process.env, { TMPDIR: sandbox, TEMP: sandbox, TMP: sandbox });
|
|
10051
|
+
const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: proj, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10052
|
+
R(['init', proj, '--yes']);
|
|
10053
|
+
const count = () => { try { return fs.readdirSync(sandbox).filter(x => /^(__leerness_|leerness-)/.test(x)).length; } catch { return -1; } };
|
|
10054
|
+
const delta = (a) => { const b = count(); R(a); return count() - b; };
|
|
10055
|
+
dbg.selftest = delta(['selftest']);
|
|
10056
|
+
dbg.doctor = delta(['doctor']);
|
|
10057
|
+
dbg.handoff = delta(['handoff', '.']); // 대조군 — 원래 0
|
|
10058
|
+
dbg.audit = delta(['audit']); // 대조군 — 원래 0
|
|
10059
|
+
// 반복해도 누적되지 않는가 (한 번만 0 이고 두 번째부터 새면 의미 없다)
|
|
10060
|
+
dbg.selftest2 = delta(['selftest']);
|
|
10061
|
+
// 잔존 보고는 **잔존이 있을 때만** 판별력이 있다 — 주변 상태에 기대지 말고 조건을 직접 만들고 직접 치운다.
|
|
10062
|
+
for (let i = 0; i < 250; i++) { try { fs.mkdirSync(path.join(sandbox, '__leerness_probe_' + i)); } catch {} }
|
|
10063
|
+
let dj = null; try { dj = JSON.parse(((R(['doctor', '--json']).stdout) || '').trim()); } catch {}
|
|
10064
|
+
const beforeReport = count();
|
|
10065
|
+
R(['doctor']);
|
|
10066
|
+
dbg.doctorReadOnly = count() - beforeReport === 0; // doctor 는 지우지 않는다(사용자 디스크)
|
|
10067
|
+
dbg.reportsLeftover = !!dj && !!dj.tmpLeftover && dj.tmpLeftover.count >= 250;
|
|
10068
|
+
dbg.healthyUnaffected = !!dj && dj.healthy === true; // 잔존은 '문제 감지' 가 아니다
|
|
10069
|
+
// codex 검수 LOW#4: **파일**을 "임시 디렉토리" 로 세면 안 된다 (leerness-note.txt 210개는 잔존 0)
|
|
10070
|
+
const fbox = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-fbox-'));
|
|
10071
|
+
for (let i = 0; i < 210; i++) fs.writeFileSync(path.join(fbox, 'leerness-user-note-' + i + '.txt'), 'x');
|
|
10072
|
+
let fj = null; try { fj = JSON.parse(((cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { cwd: proj, encoding: 'utf8', timeout: 300000, env: Object.assign({}, process.env, { TMPDIR: fbox, TEMP: fbox, TMP: fbox }) }).stdout) || '').trim()); } catch {}
|
|
10073
|
+
dbg.filesNotCounted = !!fj && fj.tmpLeftover === null;
|
|
10074
|
+
// codex 검수 LOW#5: Windows 는 대소문자를 구분하지 않는다 — 대문자 접두도 잡아야 한다
|
|
10075
|
+
const cbox = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-cbox-'));
|
|
10076
|
+
for (let i = 0; i < 210; i++) fs.mkdirSync(path.join(cbox, '__LEERNESS_probe_' + i));
|
|
10077
|
+
let cj = null; try { cj = JSON.parse(((cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { cwd: proj, encoding: 'utf8', timeout: 300000, env: Object.assign({}, process.env, { TMPDIR: cbox, TEMP: cbox, TMP: cbox }) }).stdout) || '').trim()); } catch {}
|
|
10078
|
+
dbg.caseInsensitive = !!cj && !!cj.tmpLeftover && cj.tmpLeftover.count >= 210;
|
|
10079
|
+
for (const _b of [fbox, cbox]) { try { fs.rmSync(_b, { recursive: true, force: true }); } catch {} }
|
|
10080
|
+
ok = dbg.selftest === 0 && dbg.doctor === 0 && dbg.selftest2 === 0
|
|
10081
|
+
&& dbg.handoff === 0 && dbg.audit === 0 && dbg.doctorReadOnly && dbg.reportsLeftover && dbg.healthyUnaffected
|
|
10082
|
+
&& dbg.filesNotCounted && dbg.caseInsensitive;
|
|
10083
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { for (const _p of [proj, sandbox]) { try { fs.rmSync(_p, { recursive: true, force: true }); } catch {} } }
|
|
10084
|
+
console.log(ok ? '✓ I(1.36.106/T-0090) selftest·doctor 임시 디렉토리 누수 0 (반복 포함) + 사용자 명령 대조군 0 + 잔존은 보고만'
|
|
10085
|
+
: '✗ T-0090 임시 디렉토리 누수 ' + JSON.stringify(dbg));
|
|
10086
|
+
if (!ok) failed++;
|
|
10087
|
+
}
|
|
10088
|
+
|
|
9891
10089
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
9892
10090
|
if (failed > 0) process.exit(1);
|
|
9893
10091
|
|