leerness 1.36.112 → 1.36.113
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 +54 -41
- package/lib/clarify.js +4 -1
- package/package.json +1 -1
- package/scripts/e2e.js +128 -0
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.113 하네스를 사용합니다. 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.113는 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.112는 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.113 릴리스 태그 이력** (수백 라운드) · _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.113: 2026-08-11
|
|
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.113';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -9832,8 +9832,8 @@ function resumeCmd(root) {
|
|
|
9832
9832
|
log('');
|
|
9833
9833
|
log(grn(`## 사전 정리된 next-actions (${plan.nextActions.length}건)`));
|
|
9834
9834
|
for (const a of plan.nextActions) {
|
|
9835
|
-
log(` ${a.icon || '•'} ${a.title}`);
|
|
9836
|
-
if (a.command) log(dim(` \`${a.command}\``));
|
|
9835
|
+
log(` ${a.icon || '•'} ${_lineSafe(a.title)}`);
|
|
9836
|
+
if (a.command) log(dim(` \`${_lineSafe(a.command)}\``));
|
|
9837
9837
|
}
|
|
9838
9838
|
log('');
|
|
9839
9839
|
log(dim(` → 즉시 task 추가: leerness next-action take`));
|
|
@@ -9898,7 +9898,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9898
9898
|
}
|
|
9899
9899
|
log(yel(` 📥 delivered 패턴 후보 ${detected.candidates.length}건:`));
|
|
9900
9900
|
detected.candidates.forEach(c => {
|
|
9901
|
-
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${c.text.slice(0, 80)}…`);
|
|
9901
|
+
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${_lineSafe(c.text).slice(0, 80)}…`);
|
|
9902
9902
|
});
|
|
9903
9903
|
log('');
|
|
9904
9904
|
if (apply) {
|
|
@@ -9939,7 +9939,11 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9939
9939
|
for (const r of list) {
|
|
9940
9940
|
const statusIcon = r.status === 'completed' ? '✓' : (r.status === 'dropped' ? '✗' : (r.status === 'in-progress' ? '▶' : '◯'));
|
|
9941
9941
|
const recordedDay = (r.recordedAt || '').slice(0, 10);
|
|
9942
|
-
|
|
9942
|
+
// 1.36.113: 목록 줄에 raw 텍스트를 넣으면 사용자 요청의 개행이 **두 번째 줄**을 만든다. 그 줄은
|
|
9943
|
+
// `◯ [UR-9999] …` 처럼 진짜 항목과 구별되지 않아, 이 목록을 읽는 AI 가 없는 요청을 실재로 오인한다.
|
|
9944
|
+
// 적대적 입력만의 문제가 아니다 — 사용자가 여러 줄로 요청을 적는 것은 평범한 일이다(실측에서 갈라졌다).
|
|
9945
|
+
// 터미널 출력에 _lineSafe 를 거는 것은 이 저장소의 기존 방식이다(compact/statusline 이 이미 그렇게 한다).
|
|
9946
|
+
log(` ${statusIcon} [${r.id}] ${dim(recordedDay)} ${_lineSafe(r.text).slice(0, 100)}${r.text.length > 100 ? '…' : ''}`);
|
|
9943
9947
|
if (r.linkedTaskIds && r.linkedTaskIds.length > 0) log(dim(` linked tasks: ${r.linkedTaskIds.join(', ')}`));
|
|
9944
9948
|
}
|
|
9945
9949
|
return;
|
|
@@ -9977,7 +9981,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9977
9981
|
} else {
|
|
9978
9982
|
log(red(` ⚠ 누락 후보 ${audit.missing.length}건 (open 상태이나 task/plan/decisions 매칭 없음):`));
|
|
9979
9983
|
for (const m of audit.missing) {
|
|
9980
|
-
log(` • [${m.id}] ${m.text.slice(0, 90)}${m.text.length > 90 ? '…' : ''}`);
|
|
9984
|
+
log(` • [${m.id}] ${_lineSafe(m.text).slice(0, 90)}${m.text.length > 90 ? '…' : ''}`);
|
|
9981
9985
|
log(dim(` ${m.recordedAt.slice(0, 10)} · hits=${m.hits}/${m.words}`));
|
|
9982
9986
|
}
|
|
9983
9987
|
log('');
|
|
@@ -9987,7 +9991,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9987
9991
|
log('');
|
|
9988
9992
|
log(grn(` ✓ tracked ${audit.tracked.length}건 (open + task/plan/decisions 매칭됨):`));
|
|
9989
9993
|
for (const t of audit.tracked.slice(0, 5)) {
|
|
9990
|
-
log(` • [${t.id}] ${t.text.slice(0, 80)}${t.text.length > 80 ? '…' : ''} ${dim(`(hits=${t.hits})`)}`);
|
|
9994
|
+
log(` • [${t.id}] ${_lineSafe(t.text).slice(0, 80)}${t.text.length > 80 ? '…' : ''} ${dim(`(hits=${t.hits})`)}`);
|
|
9991
9995
|
}
|
|
9992
9996
|
}
|
|
9993
9997
|
if (audit.stale.length > 0) {
|
|
@@ -9995,7 +9999,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9995
9999
|
log(yel(` ⏳ stale ${audit.stale.length}건 (7일+ open):`));
|
|
9996
10000
|
for (const s of audit.stale.slice(0, 5)) {
|
|
9997
10001
|
const days = Math.floor((Date.now() - new Date(s.recordedAt).getTime()) / 86400000);
|
|
9998
|
-
log(` • [${s.id}] ${days}일 ${s.text.slice(0, 70)}…`);
|
|
10002
|
+
log(` • [${s.id}] ${days}일 ${_lineSafe(s.text).slice(0, 70)}…`);
|
|
9999
10003
|
}
|
|
10000
10004
|
}
|
|
10001
10005
|
return;
|
|
@@ -11128,8 +11132,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11128
11132
|
log('');
|
|
11129
11133
|
for (let i = 0; i < state.queue.length; i++) {
|
|
11130
11134
|
const a = state.queue[i];
|
|
11131
|
-
log(` [${i}] ${a.icon || '•'} ${a.title}`);
|
|
11132
|
-
if (a.command) log(` \`${a.command}\``);
|
|
11135
|
+
log(` [${i}] ${a.icon || '•'} ${_lineSafe(a.title)}`); // 1.36.113: 개행이 가짜 큐 항목 줄을 만든다
|
|
11136
|
+
if (a.command) log(` \`${_lineSafe(a.command)}\``);
|
|
11133
11137
|
}
|
|
11134
11138
|
log('');
|
|
11135
11139
|
log(` → 가져오기: leerness next-action take [N] (N 생략 시 최신 [${state.queue.length - 1}])`);
|
|
@@ -11141,8 +11145,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11141
11145
|
if (isNaN(n) || n < 0 || n >= state.queue.length) { fail(`잘못된 index: ${n} (0~${state.queue.length - 1})`); return process.exit(1); }
|
|
11142
11146
|
const action = state.queue[n];
|
|
11143
11147
|
log(`# leerness next-action take [${n}] (1.9.201)`);
|
|
11144
|
-
log(` ${action.icon || '•'} ${action.title}`);
|
|
11145
|
-
if (action.command) log(` \`${action.command}\``);
|
|
11148
|
+
log(` ${action.icon || '•'} ${_lineSafe(action.title)}`);
|
|
11149
|
+
if (action.command) log(` \`${_lineSafe(action.command)}\``);
|
|
11146
11150
|
// task add 자동 호출
|
|
11147
11151
|
try {
|
|
11148
11152
|
const taskTitle = action.title.replace(/^[^\w가-힣]+/, '').slice(0, 100);
|
|
@@ -11159,8 +11163,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11159
11163
|
const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
|
|
11160
11164
|
if (taskResult.status === 0) {
|
|
11161
11165
|
const m = (taskResult.stdout || '').match(/T-\d{4,}/);
|
|
11162
|
-
log(` ✓ task 추가: ${m ? m[0] : '?'} — "${taskTitle}"`);
|
|
11163
|
-
if (action.command) log(` 💡 실행 명령: ${action.command}`);
|
|
11166
|
+
log(` ✓ task 추가: ${m ? m[0] : '?'} — "${_lineSafe(taskTitle)}"`); // 1.36.113: 확인 줄도 같은 클래스다
|
|
11167
|
+
if (action.command) log(` 💡 실행 명령: ${_lineSafe(action.command)}`);
|
|
11164
11168
|
} else {
|
|
11165
11169
|
log(` ⚠ task add 실패 (exit ${taskResult.status}) — 수동: leerness task add "${taskTitle}"`);
|
|
11166
11170
|
}
|
|
@@ -11678,7 +11682,11 @@ function planAdd(root, text) {
|
|
|
11678
11682
|
// 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
|
|
11679
11683
|
const { id, tid } = _withLock(progressPath(root), () => {
|
|
11680
11684
|
const id = nextId(root, 'M');
|
|
11681
|
-
|
|
11685
|
+
// 1.36.113 (방치 표면 사냥): `text` 만 raw 였다 — 같은 문장의 doneWhen 은 1.36.63 검수가 _lineSafe 를 걸었고
|
|
11686
|
+
// 바로 아래 planDrop 은 _cellSafe 를 쓰는데, 여기만 빠져 있었다(수정 클래스 스윕 누락의 전형).
|
|
11687
|
+
// 개행 하나로 `### M-9999. 가짜 마일스톤` 헤더를 plan.md 에 위조할 수 있고, **plan.md 는 handoff 가 읽는다** —
|
|
11688
|
+
// 측정한 9개 사용자텍스트 표면 중 handoff 까지 전파되는 유일한 지점이었다.
|
|
11689
|
+
append(planPath(root), `\n### ${id}. ${_lineSafe(text)}\nStatus: ${status}\nProgress: ${_lineSafe(progress)}%\nDone-When: ${doneWhen}\n\nTasks:\n- [ ] ${_lineSafe(text)}\n`);
|
|
11682
11690
|
const tid = nextId(root, 'T');
|
|
11683
11691
|
upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction });
|
|
11684
11692
|
return { id, tid };
|
|
@@ -12282,8 +12290,8 @@ function lessonListCmd(root, opts = {}) {
|
|
|
12282
12290
|
}
|
|
12283
12291
|
log(`총 ${lessons.length}건${tagFilter ? ` (tag: ${tagFilter})` : ''}${queryFilter ? ` (query: "${queryFilter}")` : ''}:`);
|
|
12284
12292
|
for (const l of lessons) {
|
|
12285
|
-
log(`\n[${l.date || '?'}]${l.tag ? ` #${l.tag}` : ''}`);
|
|
12286
|
-
log(` ${l.text}`);
|
|
12293
|
+
log(`\n[${_lineSafe(l.date || '?')}]${l.tag ? ` #${_lineSafe(l.tag)}` : ''}`);
|
|
12294
|
+
log(` ${_lineSafe(l.text)}`); // 1.36.113: 저장(md)만 막혀 있고 목록 출력은 raw 였다
|
|
12287
12295
|
}
|
|
12288
12296
|
}
|
|
12289
12297
|
|
|
@@ -12371,11 +12379,13 @@ function decisionListCmd(root, opts = {}) {
|
|
|
12371
12379
|
log(`# 🧠 Decisions (1.9.118)${queryFilter ? ` — query: "${queryFilter}"` : ''}\n`);
|
|
12372
12380
|
if (!decisions.length) return ok(queryFilter ? `"${queryFilter}" 매칭 decision 없음` : 'decisions 비어있음');
|
|
12373
12381
|
log(`총 ${decisions.length}건${queryFilter ? ` (query: "${queryFilter}")` : ''}:`);
|
|
12382
|
+
// 1.36.113: 저장(md)은 _lineSafe 로 막혀 있는데 **이 목록 출력만** raw 였다 — 보호가 파일에만 걸려 있었다.
|
|
12383
|
+
// 개행이 있으면 `[2099-01-01] 가짜 결정` 같은 줄이 별도 항목처럼 보인다(사람도 AI 도 구별 못 한다).
|
|
12374
12384
|
for (const d of decisions) {
|
|
12375
|
-
log(`\n[${d.date || '?'}] ${d.title}`);
|
|
12376
|
-
if (d.reason) log(` Reason: ${d.reason}`);
|
|
12377
|
-
if (d.alternatives) log(` Alternatives: ${d.alternatives}`);
|
|
12378
|
-
if (d.impact) log(` Impact: ${d.impact}`);
|
|
12385
|
+
log(`\n[${_lineSafe(d.date || '?')}] ${_lineSafe(d.title)}`);
|
|
12386
|
+
if (d.reason) log(` Reason: ${_lineSafe(d.reason)}`);
|
|
12387
|
+
if (d.alternatives) log(` Alternatives: ${_lineSafe(d.alternatives)}`);
|
|
12388
|
+
if (d.impact) log(` Impact: ${_lineSafe(d.impact)}`);
|
|
12379
12389
|
}
|
|
12380
12390
|
}
|
|
12381
12391
|
|
|
@@ -12479,10 +12489,10 @@ function taskRelink(root) {
|
|
|
12479
12489
|
.map(r => ({ r, score: _jaccard(milestoneTokens, _tokenizeForSim(r.request)) }))
|
|
12480
12490
|
.filter(x => x.score >= minScore)
|
|
12481
12491
|
.sort((a, b) => b.score - a.score);
|
|
12482
|
-
log(`\n${m.id}: ${m.text}`);
|
|
12492
|
+
log(`\n${m.id}: ${_lineSafe(m.text)}`);
|
|
12483
12493
|
if (!candidates.length) {
|
|
12484
12494
|
log(` ⓘ 매칭 후보 없음 (score ≥ ${minScore})`);
|
|
12485
|
-
log(` → 새 task: leerness task add "${m.text}" --status planned --evidence "plan:${m.id}"`);
|
|
12495
|
+
log(` → 새 task: leerness task add "${_lineSafe(m.text)}" --status planned --evidence "plan:${m.id}"`);
|
|
12486
12496
|
continue;
|
|
12487
12497
|
}
|
|
12488
12498
|
const best = candidates[0];
|
|
@@ -14068,7 +14078,7 @@ function handoff(root) {
|
|
|
14068
14078
|
log('');
|
|
14069
14079
|
log(yl4(`## 📥 사용자 요청 자동 완료 가능 (1.9.224, ${delivered.candidates.length}건)`));
|
|
14070
14080
|
delivered.candidates.slice(0, 5).forEach(c => {
|
|
14071
|
-
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${c.text.slice(0, 70)}${c.text.length > 70 ? '…' : ''}`);
|
|
14081
|
+
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${_lineSafe(c.text).slice(0, 70)}${c.text.length > 70 ? '…' : ''}`);
|
|
14072
14082
|
});
|
|
14073
14083
|
if (delivered.candidates.length > 5) {
|
|
14074
14084
|
log(dm4(` ... +${delivered.candidates.length - 5}건 더`));
|
|
@@ -14288,8 +14298,8 @@ function handoff(root) {
|
|
|
14288
14298
|
if (_showAdvice) {
|
|
14289
14299
|
log(grn(`## 🎯 다음 단계 자동 제안 (1.9.194 E축 — 게으름 방지) — 키워드 "${keyword}"`));
|
|
14290
14300
|
for (const a of actions) {
|
|
14291
|
-
log(dim(` ${a.icon} ${a.title}`));
|
|
14292
|
-
if (a.command) log(dim(` \`${a.command}\``));
|
|
14301
|
+
log(dim(` ${a.icon} ${_lineSafe(a.title)}`));
|
|
14302
|
+
if (a.command) log(dim(` \`${_lineSafe(a.command)}\``));
|
|
14293
14303
|
}
|
|
14294
14304
|
}
|
|
14295
14305
|
// 1.9.201: queue 자동 저장 — `leerness next-action take` 로 즉시 task add 가능 (토글과 무관: 상태다)
|
|
@@ -18308,7 +18318,7 @@ function retroCmd(root) {
|
|
|
18308
18318
|
|
|
18309
18319
|
log(`\n## 🧠 최근 결정 (top 5)`);
|
|
18310
18320
|
if (!agg.recentDecisions.length) log(' (없음)');
|
|
18311
|
-
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
|
|
18321
|
+
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${_lineSafe(d.title)}`));
|
|
18312
18322
|
|
|
18313
18323
|
if (agg.durations.length >= 4) {
|
|
18314
18324
|
const mid = Math.floor(agg.durations.length / 2);
|
|
@@ -18673,7 +18683,7 @@ function _brainstormWorkspace(rootBase, topic) {
|
|
|
18673
18683
|
log(`\n## ${path.basename(p)} (${n}건)`);
|
|
18674
18684
|
if (h.decisions.length) {
|
|
18675
18685
|
log(` 🧠 결정 (${h.decisions.length})`);
|
|
18676
|
-
h.decisions.slice(0, 3).forEach(d => log(` - decisions.md:${d.line || '?'} — ${d.title}`));
|
|
18686
|
+
h.decisions.slice(0, 3).forEach(d => log(` - decisions.md:${d.line || '?'} — ${_lineSafe(d.title)}`));
|
|
18677
18687
|
}
|
|
18678
18688
|
if (h.skills.length) {
|
|
18679
18689
|
log(` 📚 스킬 (${h.skills.length})`);
|
|
@@ -18769,7 +18779,7 @@ function brainstormCmd(root, topic) {
|
|
|
18769
18779
|
// 1.9.15: 모든 출력에 출처 파일:라인 표시
|
|
18770
18780
|
if (hits.decisions.length) {
|
|
18771
18781
|
log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
|
|
18772
|
-
hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${d.title}`));
|
|
18782
|
+
hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${_lineSafe(d.title)}`));
|
|
18773
18783
|
}
|
|
18774
18784
|
if (hits.skills.length) {
|
|
18775
18785
|
log(`\n## 📚 관련 스킬 (${hits.skills.length}) — 시작 전 \`skill info <id>\` 권장`);
|
|
@@ -20439,7 +20449,7 @@ function reuseFind(root, query) {
|
|
|
20439
20449
|
log(`# reuse find: "${query}"`);
|
|
20440
20450
|
if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
|
|
20441
20451
|
log(`${matches.length}개 후보:`);
|
|
20442
|
-
for (const m of matches.slice(0, _parseLimit(arg('--limit', '20'), 20))) log(`- ${m.source}:${m.line} ${m.text}`);
|
|
20452
|
+
for (const m of matches.slice(0, _parseLimit(arg('--limit', '20'), 20))) log(`- ${m.source}:${m.line} ${_lineSafe(m.text)}`);
|
|
20443
20453
|
log(`\n💡 새로 만들기 전에 위 자원을 재사용/확장 가능한지 확인하세요.`);
|
|
20444
20454
|
}
|
|
20445
20455
|
|
|
@@ -23438,7 +23448,8 @@ function briefCmd(root, sub) {
|
|
|
23438
23448
|
const brief = _loadBrief(root);
|
|
23439
23449
|
if (has('--json')) { log(JSON.stringify(brief, null, 2)); return; }
|
|
23440
23450
|
log(cy(`# leerness brief — ${brief.project}`));
|
|
23441
|
-
|
|
23451
|
+
// 1.36.113: brief 값은 사용자가 여러 줄로 넣을 수 있고(`brief set --intro`), 그대로 찍으면 둘째 줄이 별도 항목처럼 보인다
|
|
23452
|
+
for (const f of _BRIEF_FIELDS) { const v = brief[f.key]; const filled = f.multi ? (v && v.length) : v; log(` ${filled ? gr('✓') : dm('·')} ${f.label}: ${filled ? _lineSafe(f.multi ? v.join(', ') : v).slice(0, 90) : dm('(미입력)')}`); }
|
|
23442
23453
|
log('');
|
|
23443
23454
|
log(dm(` 채움 ${_briefFilled(brief)}/${_BRIEF_FIELDS.length} · 설정: leerness brief set --intro "..." · 복사용: leerness brief export`));
|
|
23444
23455
|
return;
|
|
@@ -23503,8 +23514,8 @@ function contextCmd(root, opts = {}) {
|
|
|
23503
23514
|
const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
23504
23515
|
const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
23505
23516
|
log(cy(`# leerness context (1.9.292) — 에이전트 온보딩 컨텍스트 (v${VERSION})`));
|
|
23506
|
-
if (intent) log(` 🎯 의도: ${intent}`);
|
|
23507
|
-
if (ctx.brief.intro || ctx.brief.features.length) log(dm(` 📘 청사진: ${ctx.brief.intro || ''}${ctx.brief.features.length ? ` · 기능 ${ctx.brief.features.length}` : ''}${ctx.brief.latestDirection ? ` · 최근방향 ${ctx.brief.latestDirection.slice(0, 50)}` : ''} (leerness brief show)`));
|
|
23517
|
+
if (intent) log(` 🎯 의도: ${_lineSafe(intent)}`);
|
|
23518
|
+
if (ctx.brief.intro || ctx.brief.features.length) log(dm(` 📘 청사진: ${_lineSafe(ctx.brief.intro || '')}${ctx.brief.features.length ? ` · 기능 ${ctx.brief.features.length}` : ''}${ctx.brief.latestDirection ? ` · 최근방향 ${_lineSafe(ctx.brief.latestDirection).slice(0, 50)}` : ''} (leerness brief show)`));
|
|
23508
23519
|
log('');
|
|
23509
23520
|
if (ctx.currentTask) {
|
|
23510
23521
|
log(gr(`▶ 현재 작업: ${ctx.currentTask.id} — ${ctx.currentTask.request}`));
|
|
@@ -23512,12 +23523,12 @@ function contextCmd(root, opts = {}) {
|
|
|
23512
23523
|
} else log(dm('▶ 현재 진행 중 작업 없음'));
|
|
23513
23524
|
log('');
|
|
23514
23525
|
log(`📥 미답 요청: ${ctx.openRequests.count}건`);
|
|
23515
|
-
ctx.openRequests.items.forEach(r => log(dm(` • [${r.id}] ${r.text}`)));
|
|
23526
|
+
ctx.openRequests.items.forEach(r => log(dm(` • [${r.id}] ${_lineSafe(r.text)}`))); // 1.36.113: 개행이 가짜 항목 줄을 만든다
|
|
23516
23527
|
log('');
|
|
23517
23528
|
log(`🧠 메모리: 진행 ${memory.tasksInProgress} / 결정 ${memory.decisions} / 룰 ${memory.rulesActive} / 교훈 ${memory.lessons}`);
|
|
23518
|
-
if (recentDecisions.length) { log(''); log('🗂 최근 결정:'); recentDecisions.forEach(d => log(dm(` • ${d.date || '?'} — ${d.title}`))); }
|
|
23519
|
-
if (ctx.activeRules.length) { log(''); log('⚡ 활성 룰:'); ctx.activeRules.forEach(r => log(dm(` • [${r.id}] (${r.trigger}) ${r.rule}`))); }
|
|
23520
|
-
if (nextActions.length) { log(''); log('👉 다음 액션:'); nextActions.forEach(a => log(dm(` • ${a.title}${a.command ? ' → ' + a.command : ''}`))); }
|
|
23529
|
+
if (recentDecisions.length) { log(''); log('🗂 최근 결정:'); recentDecisions.forEach(d => log(dm(` • ${d.date || '?'} — ${_lineSafe(d.title)}`))); }
|
|
23530
|
+
if (ctx.activeRules.length) { log(''); log('⚡ 활성 룰:'); ctx.activeRules.forEach(r => log(dm(` • [${r.id}] (${r.trigger}) ${_lineSafe(r.rule)}`))); }
|
|
23531
|
+
if (nextActions.length) { log(''); log('👉 다음 액션:'); nextActions.forEach(a => log(dm(` • ${_lineSafe(a.title)}${a.command ? ' → ' + _lineSafe(a.command) : ''}`))); }
|
|
23521
23532
|
return ctx;
|
|
23522
23533
|
}
|
|
23523
23534
|
function stateCmd(root, sub, ...args) {
|
|
@@ -24938,7 +24949,7 @@ function incidentListCmd(root) {
|
|
|
24938
24949
|
try {
|
|
24939
24950
|
const j = JSON.parse(read(path.join(dir, f)));
|
|
24940
24951
|
const e = j.payload?.error || j.payload?.message || '(no description)';
|
|
24941
|
-
log(` ${j.id} · ${String(e).slice(0, 80)}`);
|
|
24952
|
+
log(` ${j.id} · ${_lineSafe(String(e)).slice(0, 80)}`);
|
|
24942
24953
|
} catch {}
|
|
24943
24954
|
}
|
|
24944
24955
|
}
|
|
@@ -24966,7 +24977,7 @@ async function incidentHandleCmd(root, id) {
|
|
|
24966
24977
|
log(`incident: ${j.id} · permission mode: ${p.mode || 'basic'}`);
|
|
24967
24978
|
const err = j.payload?.error || j.payload?.message || '';
|
|
24968
24979
|
const stack = j.payload?.stack || '';
|
|
24969
|
-
log(`error: ${String(err).slice(0, 200)}`);
|
|
24980
|
+
log(`error: ${_lineSafe(String(err)).slice(0, 200)}`);
|
|
24970
24981
|
if (stack) log(`stack head:\n${String(stack).split('\n').slice(0, 4).join('\n')}`);
|
|
24971
24982
|
// (1) feature impact 자동 회수 — error 키워드 매칭
|
|
24972
24983
|
try {
|
|
@@ -25005,7 +25016,9 @@ async function incidentHandleCmd(root, id) {
|
|
|
25005
25016
|
j.permissionMode = p.mode || 'basic';
|
|
25006
25017
|
writeUtf8(fp, JSON.stringify(j, null, 2) + '\n');
|
|
25007
25018
|
ok(`incident handled: ${j.id} (분석/회수 완료)`);
|
|
25008
|
-
|
|
25019
|
+
// 1.36.113: 이 줄은 **복붙해서 실행하라고** 주는 명령이다 — 외부 webhook 이 넣은 개행이 여기서 갈리면
|
|
25020
|
+
// 따옴표가 닫히지 않은 채 둘째 줄이 별도 명령처럼 보인다. 인용 대상은 반드시 한 줄로 접는다.
|
|
25021
|
+
log(` → 후속: leerness agent "fix: ${_lineSafe(String(err)).slice(0, 80)}" / leerness verify-code . / leerness deploy auto`);
|
|
25009
25022
|
}
|
|
25010
25023
|
|
|
25011
25024
|
// ---- (3) Webhook Listener ----
|
|
@@ -25847,7 +25860,7 @@ function lspCmd(root, sub, ...args) {
|
|
|
25847
25860
|
} else {
|
|
25848
25861
|
log(`# leerness lsp references (1.9.167)`);
|
|
25849
25862
|
log(`symbol: "${name}" · ${refs.length} references · ${dt}ms`);
|
|
25850
|
-
refs.slice(0, 30).forEach(r => log(` ${r.file}:${r.line} ${r.text}`));
|
|
25863
|
+
refs.slice(0, 30).forEach(r => log(` ${r.file}:${r.line} ${_lineSafe(r.text)}`));
|
|
25851
25864
|
if (refs.length > 30) log(` ... ${refs.length - 30} more`);
|
|
25852
25865
|
}
|
|
25853
25866
|
try { _recordRun(root, { kind: 'lsp_references', name, count: refs.length, durationMs: dt, ok: true }); } catch {}
|
package/lib/clarify.js
CHANGED
|
@@ -257,7 +257,10 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
257
257
|
|
|
258
258
|
if (sub === 'add') {
|
|
259
259
|
// 1.36.54 (#11): 제목 개행 정규화 — 한 미리보기 = 목록 한 줄 불변식
|
|
260
|
-
|
|
260
|
+
// 1.36.113 (codex 검수 P3, 재현됨): `\r?\n` 만 접으면 **단독 CR** 이 남는다. 터미널에서 CR 은 커서를 줄 앞으로
|
|
261
|
+
// 되돌려 앞부분(ID·상태)을 덮어써 목록 항목을 위장할 수 있다. JS 의 `\s` 는 CR·LF·U+2028·U+2029 를 모두 포함하므로
|
|
262
|
+
// 특수 구분자를 리터럴로 적을 필요가 없다 — 실제로 그 문자를 그대로 적었다가 파일이 SyntaxError 로 깨졌다(재발 방지).
|
|
263
|
+
const title = (rest || []).join(' ').replace(/\s+/g, ' ').trim();
|
|
261
264
|
if (!title) { failJson(json, 'title_required', 'preview add "<기능 제목>" 필요 (+ --design "설명" --features "a,b")'); return; }
|
|
262
265
|
const design = arg ? (arg('--design', '') || '') : '';
|
|
263
266
|
const features = (arg ? (arg('--features', '') || '') : '').split(',').map(s => s.trim()).filter(Boolean);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.113",
|
|
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
|
@@ -10838,6 +10838,134 @@ total++;
|
|
|
10838
10838
|
if (!ok) failed++;
|
|
10839
10839
|
}
|
|
10840
10840
|
|
|
10841
|
+
// ── 1.36.113 블록 Q: 사용자 텍스트의 **개행이 가짜 항목을 만들지 못한다** (9개 표면 × 3축 행렬).
|
|
10842
|
+
// 왜 이 라운드에 왔나: e2e 실행 커버리지를 재니 93개 명령 중 17개가 한 번도 안 돌고 있었고,
|
|
10843
|
+
// 그중 `requests` 는 **사용자 명시 요청(UR-XXXX)** 을 담는 표면이다. 거기서 시작해 클래스를 스윕했다.
|
|
10844
|
+
// 1.9.402(UR-0108)가 decisions/lessons 의 **md 투영**에 _lineSafe 를 걸었지만, 같은 클래스의
|
|
10845
|
+
// 나머지 발화점이 남아 있었다 — plan.md 쓰기(handoff 까지 전파) + 목록 출력 12곳.
|
|
10846
|
+
// 가드는 발견 하나가 아니라 **행렬 전체**를 고정한다. 한 표면만 막으면 다음 헌트에서 옆 표면이 나온다.
|
|
10847
|
+
{
|
|
10848
|
+
total++;
|
|
10849
|
+
let ok = false; const dbg = {};
|
|
10850
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-inj113-'));
|
|
10851
|
+
const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
|
|
10852
|
+
try {
|
|
10853
|
+
let seq = 0;
|
|
10854
|
+
const mk = () => {
|
|
10855
|
+
const d = path.join(sb, 'p' + (++seq)); fs.mkdirSync(d, { recursive: true });
|
|
10856
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
10857
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10858
|
+
return d;
|
|
10859
|
+
};
|
|
10860
|
+
const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10861
|
+
const M = 'ZQ9';
|
|
10862
|
+
const SURFACES = [
|
|
10863
|
+
['requests', (d, t) => R(d, ['requests', 'add', t]), (d) => [R(d, ['requests', 'list']), R(d, ['requests', 'audit'])]],
|
|
10864
|
+
['task', (d, t) => R(d, ['task', 'add', t]), (d) => [R(d, ['task', 'list'])]],
|
|
10865
|
+
['decision', (d, t) => R(d, ['decision', 'add', t, '--why', 'r']), (d) => [R(d, ['decision', 'list'])]],
|
|
10866
|
+
['lesson', (d, t) => R(d, ['lesson', 'save', t]), (d) => [R(d, ['lesson', 'list'])]],
|
|
10867
|
+
['rule', (d, t) => R(d, ['rule', 'add', t, '--trigger', 'every-round']), (d) => [R(d, ['rule', 'list'])]],
|
|
10868
|
+
['plan', (d, t) => R(d, ['plan', 'add', t]), (d) => [R(d, ['plan', 'list'])]],
|
|
10869
|
+
['next-action', (d, t) => R(d, ['next-action', 'add', t]), (d) => [R(d, ['next-action', 'list'])]],
|
|
10870
|
+
['preview', (d, t) => R(d, ['preview', 'add', t]), (d) => [R(d, ['preview', 'list'])]],
|
|
10871
|
+
['feature', (d, t) => R(d, ['feature', 'add', t]), (d) => [R(d, ['feature', 'list'])]],
|
|
10872
|
+
];
|
|
10873
|
+
// 위조 판정: 주입한 **둘째 줄 표식만** 단독으로 실린 줄이 있는가(첫 줄 표식과 같은 줄이면 안전하게 접힌 것).
|
|
10874
|
+
const forged = (txt) => String(txt).split('\n').some(l => l.includes(M + 'FORGED') && !l.includes(M + 'A'));
|
|
10875
|
+
const split = (txt) => String(txt).split('\n').some(l => /^\s*-?\s*세부 항목 1\s*$/.test(l));
|
|
10876
|
+
const bad = [];
|
|
10877
|
+
let addOk = 0, sawFirst = 0;
|
|
10878
|
+
for (const [name, add, shows] of SURFACES) {
|
|
10879
|
+
// ① 적대적: 그 표면의 목록 형식을 흉내 낸 둘째 줄
|
|
10880
|
+
const d = mk();
|
|
10881
|
+
if (add(d, `진짜 ${M}A\n ◯ [UR-9999] 2026-01-01 위조 ${M}FORGED`).status !== 0) { bad.push(`${name}:add실패`); continue; }
|
|
10882
|
+
addOk++;
|
|
10883
|
+
const outs = shows(d).map(s => String(s.stdout || ''));
|
|
10884
|
+
// 계측 판별력 — 목록이 **첫 줄 표식을 실제로 보여줘야** "위조 없음" 이 의미를 갖는다(무출력은 통과가 아니다)
|
|
10885
|
+
if (outs.some(o => o.includes(M + 'A'))) sawFirst++; else bad.push(`${name}:항목미노출`);
|
|
10886
|
+
if (outs.some(forged)) bad.push(`${name}:목록위조`);
|
|
10887
|
+
const H = path.join(d, '.harness');
|
|
10888
|
+
for (const f of fs.readdirSync(H)) {
|
|
10889
|
+
if (f.endsWith('.md') && forged(fs.readFileSync(path.join(H, f), 'utf8'))) bad.push(`${name}:파일오염(${f})`);
|
|
10890
|
+
}
|
|
10891
|
+
// handoff 는 다음 세션 AI 가 통째로 읽는다 — 전파 경로 중 가장 넓다(plan.md 가 이 경로로 샜었다)
|
|
10892
|
+
if (forged(R(d, ['handoff', d, '--no-drift-check', '--no-record']).stdout)) bad.push(`${name}:handoff전파`);
|
|
10893
|
+
// ② 평범한 여러 줄 — 적대적 입력이 아니라 **일상 입력**에서 갈라지면 그게 더 흔한 피해다.
|
|
10894
|
+
// ⚠ 초안은 `add 성공 && 분리됨` 이라 **add 가 실패하면 조용히 통과**했다(codex 검수 P2, 재현됨) —
|
|
10895
|
+
// "일상 입력도 지원한다" 는 주장이 공허해진다. 성공·노출을 각각 단언한다.
|
|
10896
|
+
const d2 = mk();
|
|
10897
|
+
if (add(d2, `첫 줄 요약 ${M}B\n- 세부 항목 1\n- 세부 항목 2`).status !== 0) bad.push(`${name}:여러줄add실패`);
|
|
10898
|
+
else {
|
|
10899
|
+
const o2 = shows(d2).map(s => String(s.stdout || ''));
|
|
10900
|
+
if (!o2.some(o => o.includes(M + 'B'))) bad.push(`${name}:여러줄항목미노출`);
|
|
10901
|
+
if (o2.some(split)) bad.push(`${name}:여러줄분리`);
|
|
10902
|
+
}
|
|
10903
|
+
|
|
10904
|
+
// ③ **플래그 경로**도 같은 클래스다. `plan add --progress` 가 raw 로 plan.md 에 들어가
|
|
10905
|
+
// `### M-9999.` 헤더를 위조했다(codex 검수 P1, 재현 — plan list·handoff 까지 전파).
|
|
10906
|
+
// positional 만 찌르면 이 경로를 통째로 놓친다.
|
|
10907
|
+
if (name === 'plan') {
|
|
10908
|
+
const d3 = mk();
|
|
10909
|
+
R(d3, ['plan', 'add', `진짜 ${M}A`, '--progress', `0%\n### M-9999. 위조 ${M}FORGED\nStatus: planned\nProgress: 0`]);
|
|
10910
|
+
const pf = fs.readFileSync(path.join(d3, '.harness', 'plan.md'), 'utf8');
|
|
10911
|
+
// ⚠ 판정은 **구조**로 한다. 안전화되면 주입 문자열은 `Progress:` 값 안에 한 줄로 남으므로
|
|
10912
|
+
// 단순 `M-9999` 부분일치는 고쳐진 뒤에도 참이다(작성 중 실제로 오탐을 냈다).
|
|
10913
|
+
if (/^### M-9999\./m.test(pf)) bad.push('plan:progress플래그로_파일위조');
|
|
10914
|
+
let ms = null;
|
|
10915
|
+
try { const j = JSON.parse(String(R(d3, ['plan', 'list', '--json']).stdout || '')); ms = (j.milestones || j.plan || []).map(x => x.id); } catch {}
|
|
10916
|
+
if (!ms) bad.push('plan:progress케이스_json파싱실패');
|
|
10917
|
+
else if (ms.includes('M-9999')) bad.push('plan:progress플래그로_마일스톤위조');
|
|
10918
|
+
else if (ms.length !== 2) bad.push(`plan:progress케이스_마일스톤수이상(${ms.length})`); // 계측 판별력: 기본 1 + 방금 추가 1
|
|
10919
|
+
if (!/진짜 ZQ9A/.test(pf)) bad.push('plan:progress케이스_본문미기록');
|
|
10920
|
+
}
|
|
10921
|
+
}
|
|
10922
|
+
// ④ 목록 **외의** 출력 경로도 같은 클래스다. codex 검수가 여기서 4건을 더 찾았다 —
|
|
10923
|
+
// 목록만 막고 끝내면 "고쳤다" 는 말이 절반만 참이 된다(발견이 아니라 클래스를 스윕해야 하는 이유).
|
|
10924
|
+
const line2 = (out, first, forgedMark) => String(out).split('\n').some(l => l.includes(forgedMark) && !l.includes(first));
|
|
10925
|
+
{
|
|
10926
|
+
// next-action take — 큐 제목이 take 확인 줄에서 다시 갈렸다
|
|
10927
|
+
const d = mk();
|
|
10928
|
+
R(d, ['next-action', 'add', `진짜 ${M}A\n [99] 📝 위조 ${M}FORGED`]);
|
|
10929
|
+
const o = String(R(d, ['next-action', 'take', '0']).stdout || '');
|
|
10930
|
+
if (!o.includes(M + 'A')) bad.push('take:항목미노출');
|
|
10931
|
+
if (line2(o, M + 'A', M + 'FORGED')) bad.push('take:위조');
|
|
10932
|
+
}
|
|
10933
|
+
{
|
|
10934
|
+
// incident — payload 는 **외부 webhook 입력**이다. 목록·처리·후속명령 세 줄 모두 본다.
|
|
10935
|
+
const d = mk();
|
|
10936
|
+
const idir = path.join(d, '.harness', 'incidents'); fs.mkdirSync(idir, { recursive: true });
|
|
10937
|
+
fs.writeFileSync(path.join(idir, 'inc-1.json'), JSON.stringify({
|
|
10938
|
+
id: 'inc-1', at: new Date().toISOString(), source: 'webhook', status: 'open',
|
|
10939
|
+
payload: { error: `실제 ${M}A\n inc-9999 · 위조 ${M}FORGED` } }, null, 2));
|
|
10940
|
+
const o = String(R(d, ['incident', 'list']).stdout || '') + '\n' + String(R(d, ['incident', 'handle', 'inc-1']).stdout || '');
|
|
10941
|
+
if (!o.includes(M + 'A')) bad.push('incident:항목미노출');
|
|
10942
|
+
if (line2(o, M + 'A', M + 'FORGED')) bad.push('incident:위조');
|
|
10943
|
+
}
|
|
10944
|
+
{
|
|
10945
|
+
// brief — 사용자가 여러 줄로 넣을 수 있고, brief show 와 context 두 표면이 읽는다
|
|
10946
|
+
const d = mk();
|
|
10947
|
+
R(d, ['brief', 'set', '--intro', `정상 ${M}A\n • 위조 ${M}FORGED`]);
|
|
10948
|
+
const o = String(R(d, ['brief', 'show']).stdout || '') + '\n' + String(R(d, ['context']).stdout || '');
|
|
10949
|
+
if (!o.includes(M + 'A')) bad.push('brief:항목미노출');
|
|
10950
|
+
if (line2(o, M + 'A', M + 'FORGED')) bad.push('brief:위조');
|
|
10951
|
+
}
|
|
10952
|
+
{
|
|
10953
|
+
// preview 제목의 **단독 CR** — 개행이 아니라 커서 되돌림으로 앞부분(ID·상태)을 덮어쓴다
|
|
10954
|
+
const d = mk();
|
|
10955
|
+
R(d, ['preview', 'add', `정상 ${M}A\r위조 ${M}FORGED`]);
|
|
10956
|
+
const o = String(R(d, ['preview', 'list']).stdout || '');
|
|
10957
|
+
if (!o.includes(M + 'A')) bad.push('preview:항목미노출');
|
|
10958
|
+
if (/\r/.test(o)) bad.push('preview:단독CR잔존');
|
|
10959
|
+
}
|
|
10960
|
+
dbg.addOk = addOk; dbg.sawFirst = sawFirst; dbg.bad = bad.slice(0, 12);
|
|
10961
|
+
// 대조군: 9개 표면이 전부 add 성공 + 항목 노출이어야 판정이 성립한다(조용한 실패가 '위조 0' 으로 둔갑하지 못하게)
|
|
10962
|
+
ok = addOk === SURFACES.length && sawFirst === SURFACES.length && bad.length === 0;
|
|
10963
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
10964
|
+
console.log(ok ? `✓ Q(1.36.113) 개행 주입 차단 행렬: 9표면 × (목록·.md파일·handoff) 위조 0 · plan --progress 플래그 경로 · next-action take · incident(외부입력) · brief/context · preview 단독CR · 평범한 여러 줄도 안 갈라짐 · 계측 살아있음(9/9 노출)`
|
|
10965
|
+
: '✗ 1.36.113 개행 주입 차단 실패 ' + JSON.stringify(dbg));
|
|
10966
|
+
if (!ok) failed++;
|
|
10967
|
+
}
|
|
10968
|
+
|
|
10841
10969
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
10842
10970
|
if (failed > 0) process.exit(1);
|
|
10843
10971
|
|