leerness 1.36.112 → 1.36.114
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 +120 -68
- package/lib/clarify.js +4 -1
- package/package.json +1 -1
- package/scripts/e2e.js +307 -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.114 하네스를 사용합니다. 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.114는 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.114 릴리스 태그 이력** (수백 라운드) · _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.114: 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.114';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -7085,9 +7085,17 @@ function _assertStoreParsable(file, label) {
|
|
|
7085
7085
|
catch { throw Object.assign(new Error(`${label} 저장 파일이 손상돼(JSON 파싱 실패) 덮어쓰기를 거부합니다: ${file} — 파일을 복구하거나 삭제 후 재시도하세요`), { code: 'E_STORE_CORRUPT', file }); }
|
|
7086
7086
|
}
|
|
7087
7087
|
}
|
|
7088
|
+
// 1.36.114: 동기 try/catch 만 있어 **async 명령에서는 통째로 무력**이었다 — 던진 예외가 거부된 프로미스가 되어
|
|
7089
|
+
// 래퍼를 그냥 지나갔다. 실측: `next-action add --json` 이 사람용 `✗` 줄을 JSON **앞에** 흘리고
|
|
7090
|
+
// code 가 `store_corrupt` 대신 `error` 였다(기계 계약 파손: JSON.parse(stdout) 실패).
|
|
7091
|
+
// 1.36.107 이 `_withLock` 에서 고친 것과 같은 형태다 — thenable 이면 프로미스 경로로도 같은 처리를 한다.
|
|
7088
7092
|
function _guardStore(jsonMode, fn) {
|
|
7089
|
-
|
|
7090
|
-
|
|
7093
|
+
const handle = (e) => { if (e && e.code === 'E_STORE_CORRUPT') { failJson(jsonMode, 'store_corrupt', e.message); return undefined; } throw e; };
|
|
7094
|
+
let out;
|
|
7095
|
+
try { out = fn(); }
|
|
7096
|
+
catch (e) { return handle(e); }
|
|
7097
|
+
if (out && typeof out.then === 'function') return out.then(v => v, handle);
|
|
7098
|
+
return out;
|
|
7091
7099
|
}
|
|
7092
7100
|
// 1.36.28 (#4): 0-deps 짧은 결정적 해시 (URL 충돌 구분용, 보안 아님).
|
|
7093
7101
|
function _shortHash(s) {
|
|
@@ -9433,9 +9441,13 @@ function _loadWakeupHistory(root) {
|
|
|
9433
9441
|
function _writeWakeupHistory(root, state) {
|
|
9434
9442
|
try {
|
|
9435
9443
|
mkdirp(path.join(root, '.harness'));
|
|
9444
|
+
_assertStoreParsable(_wakeupHistoryPath(root), 'wakeup-history'); // 1.36.114: 손상 스토어 덮어쓰기 거부
|
|
9436
9445
|
writeUtf8(_wakeupHistoryPath(root), JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2));
|
|
9437
9446
|
return true;
|
|
9438
|
-
} catch {
|
|
9447
|
+
} catch (e) {
|
|
9448
|
+
if (e && e.code === 'E_STORE_CORRUPT') throw e; // 1.36.114: 조용한 무동작 + 성공 표시 방지(위 _writeNextActionQueue 와 같은 이유)
|
|
9449
|
+
return false;
|
|
9450
|
+
}
|
|
9439
9451
|
}
|
|
9440
9452
|
// 1.36.108 (T-0097): append 형 RMW — 락 밖이면 동시 기록이 서로를 지운다.
|
|
9441
9453
|
// 이 스토어는 자동(스케줄)과 사용자 트리거가 동시에 들어올 수 있어 겹칠 여지가 실제로 있다.
|
|
@@ -9832,8 +9844,8 @@ function resumeCmd(root) {
|
|
|
9832
9844
|
log('');
|
|
9833
9845
|
log(grn(`## 사전 정리된 next-actions (${plan.nextActions.length}건)`));
|
|
9834
9846
|
for (const a of plan.nextActions) {
|
|
9835
|
-
log(` ${a.icon || '•'} ${a.title}`);
|
|
9836
|
-
if (a.command) log(dim(` \`${a.command}\``));
|
|
9847
|
+
log(` ${a.icon || '•'} ${_lineSafe(a.title)}`);
|
|
9848
|
+
if (a.command) log(dim(` \`${_lineSafe(a.command)}\``));
|
|
9837
9849
|
}
|
|
9838
9850
|
log('');
|
|
9839
9851
|
log(dim(` → 즉시 task 추가: leerness next-action take`));
|
|
@@ -9898,7 +9910,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9898
9910
|
}
|
|
9899
9911
|
log(yel(` 📥 delivered 패턴 후보 ${detected.candidates.length}건:`));
|
|
9900
9912
|
detected.candidates.forEach(c => {
|
|
9901
|
-
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${c.text.slice(0, 80)}…`);
|
|
9913
|
+
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${_lineSafe(c.text).slice(0, 80)}…`);
|
|
9902
9914
|
});
|
|
9903
9915
|
log('');
|
|
9904
9916
|
if (apply) {
|
|
@@ -9939,7 +9951,11 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9939
9951
|
for (const r of list) {
|
|
9940
9952
|
const statusIcon = r.status === 'completed' ? '✓' : (r.status === 'dropped' ? '✗' : (r.status === 'in-progress' ? '▶' : '◯'));
|
|
9941
9953
|
const recordedDay = (r.recordedAt || '').slice(0, 10);
|
|
9942
|
-
|
|
9954
|
+
// 1.36.113: 목록 줄에 raw 텍스트를 넣으면 사용자 요청의 개행이 **두 번째 줄**을 만든다. 그 줄은
|
|
9955
|
+
// `◯ [UR-9999] …` 처럼 진짜 항목과 구별되지 않아, 이 목록을 읽는 AI 가 없는 요청을 실재로 오인한다.
|
|
9956
|
+
// 적대적 입력만의 문제가 아니다 — 사용자가 여러 줄로 요청을 적는 것은 평범한 일이다(실측에서 갈라졌다).
|
|
9957
|
+
// 터미널 출력에 _lineSafe 를 거는 것은 이 저장소의 기존 방식이다(compact/statusline 이 이미 그렇게 한다).
|
|
9958
|
+
log(` ${statusIcon} [${r.id}] ${dim(recordedDay)} ${_lineSafe(r.text).slice(0, 100)}${r.text.length > 100 ? '…' : ''}`);
|
|
9943
9959
|
if (r.linkedTaskIds && r.linkedTaskIds.length > 0) log(dim(` linked tasks: ${r.linkedTaskIds.join(', ')}`));
|
|
9944
9960
|
}
|
|
9945
9961
|
return;
|
|
@@ -9977,7 +9993,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9977
9993
|
} else {
|
|
9978
9994
|
log(red(` ⚠ 누락 후보 ${audit.missing.length}건 (open 상태이나 task/plan/decisions 매칭 없음):`));
|
|
9979
9995
|
for (const m of audit.missing) {
|
|
9980
|
-
log(` • [${m.id}] ${m.text.slice(0, 90)}${m.text.length > 90 ? '…' : ''}`);
|
|
9996
|
+
log(` • [${m.id}] ${_lineSafe(m.text).slice(0, 90)}${m.text.length > 90 ? '…' : ''}`);
|
|
9981
9997
|
log(dim(` ${m.recordedAt.slice(0, 10)} · hits=${m.hits}/${m.words}`));
|
|
9982
9998
|
}
|
|
9983
9999
|
log('');
|
|
@@ -9987,7 +10003,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9987
10003
|
log('');
|
|
9988
10004
|
log(grn(` ✓ tracked ${audit.tracked.length}건 (open + task/plan/decisions 매칭됨):`));
|
|
9989
10005
|
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})`)}`);
|
|
10006
|
+
log(` • [${t.id}] ${_lineSafe(t.text).slice(0, 80)}${t.text.length > 80 ? '…' : ''} ${dim(`(hits=${t.hits})`)}`);
|
|
9991
10007
|
}
|
|
9992
10008
|
}
|
|
9993
10009
|
if (audit.stale.length > 0) {
|
|
@@ -9995,7 +10011,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9995
10011
|
log(yel(` ⏳ stale ${audit.stale.length}건 (7일+ open):`));
|
|
9996
10012
|
for (const s of audit.stale.slice(0, 5)) {
|
|
9997
10013
|
const days = Math.floor((Date.now() - new Date(s.recordedAt).getTime()) / 86400000);
|
|
9998
|
-
log(` • [${s.id}] ${days}일 ${s.text.slice(0, 70)}…`);
|
|
10014
|
+
log(` • [${s.id}] ${days}일 ${_lineSafe(s.text).slice(0, 70)}…`);
|
|
9999
10015
|
}
|
|
10000
10016
|
}
|
|
10001
10017
|
return;
|
|
@@ -10143,11 +10159,11 @@ function constraintsCmd(root, sub, ...rest) {
|
|
|
10143
10159
|
for (const [pid, plat] of Object.entries(catalog.platforms)) {
|
|
10144
10160
|
// 1.31.2: aliases are matchers (kept for matching); hide Hangul-only aliases from EN display
|
|
10145
10161
|
const aliasList = _L === 'en' ? (plat.aliases || []).filter(a => !/[가-힣]/.test(a)) : (plat.aliases || []);
|
|
10146
|
-
log(grn(` 📦 ${pid}`) + dim(` aliases: ${aliasList.join(', ')}`));
|
|
10147
|
-
log(dim(` docs: ${plat.docs || '-'}`));
|
|
10162
|
+
log(grn(` 📦 ${_lineSafe(pid)}`) + dim(` aliases: ${_lineSafe(aliasList.join(', '))}`));
|
|
10163
|
+
log(dim(` docs: ${_lineSafe(plat.docs || '-')}`));
|
|
10148
10164
|
for (const c of plat.constraints || []) {
|
|
10149
10165
|
const icon = c.kind === 'rate-limit' ? '🚦' : (c.kind === 'cost' ? '💰' : (c.kind === 'auth' ? '🔐' : '📋'));
|
|
10150
|
-
log(` ${icon} [${c.kind}] ${_L === 'en' && c.detailEn ? c.detailEn : c.detail}`);
|
|
10166
|
+
log(` ${icon} [${_lineSafe(c.kind)}] ${_lineSafe(_L === 'en' && c.detailEn ? c.detailEn : c.detail)}`); // 1.36.114 (검수): ID·alias 만 막고 kind/detail 이 raw 였다
|
|
10151
10167
|
}
|
|
10152
10168
|
log('');
|
|
10153
10169
|
}
|
|
@@ -10176,11 +10192,11 @@ function constraintsCmd(root, sub, ...rest) {
|
|
|
10176
10192
|
log(red(_t(` ⚠ ${result.matched.length}개 플랫폼 매칭 — 제약 사전 확인 필요:`, ` ⚠ ${result.matched.length} platform(s) matched — review constraints before building:`)));
|
|
10177
10193
|
log('');
|
|
10178
10194
|
for (const m of result.matched) {
|
|
10179
|
-
log(grn(` 📦 ${m.platform}`) + dim(` (matched: "${m.matchedAlias}")`));
|
|
10180
|
-
log(dim(` docs: ${m.docs || '-'}`));
|
|
10195
|
+
log(grn(` 📦 ${_lineSafe(m.platform)}`) + dim(` (matched: "${_lineSafe(m.matchedAlias)}")`));
|
|
10196
|
+
log(dim(` docs: ${_lineSafe(m.docs || '-')}`));
|
|
10181
10197
|
for (const c of m.constraints || []) {
|
|
10182
10198
|
const icon = c.kind === 'rate-limit' ? '🚦' : (c.kind === 'cost' ? '💰' : (c.kind === 'auth' ? '🔐' : '📋'));
|
|
10183
|
-
log(` ${icon} [${c.kind}] ${_L === 'en' && c.detailEn ? c.detailEn : c.detail}`);
|
|
10199
|
+
log(` ${icon} [${_lineSafe(c.kind)}] ${_lineSafe(_L === 'en' && c.detailEn ? c.detailEn : c.detail)}`); // 1.36.114 (검수): ID·alias 만 막고 kind/detail 이 raw 였다
|
|
10184
10200
|
}
|
|
10185
10201
|
log('');
|
|
10186
10202
|
}
|
|
@@ -10211,7 +10227,7 @@ function constraintsCmd(root, sub, ...rest) {
|
|
|
10211
10227
|
if (!_cons.some(c => c.kind === kindTrim && c.detail === detail)) _cons.push({ kind: kindTrim, detail });
|
|
10212
10228
|
_writePlatformConstraints(root, catalog);
|
|
10213
10229
|
if (has('--json')) { log(JSON.stringify(catalog.platforms[id], null, 2)); return; }
|
|
10214
|
-
log(grn(_t(`✓ platform "${id}" 갱신 — constraints: ${catalog.platforms[id].constraints.length}`, `✓ platform "${id}" updated — constraints: ${catalog.platforms[id].constraints.length}`)));
|
|
10230
|
+
log(grn(_t(`✓ platform "${_lineSafe(id)}" 갱신 — constraints: ${catalog.platforms[id].constraints.length}`, `✓ platform "${_lineSafe(id)}" updated — constraints: ${catalog.platforms[id].constraints.length}`)));
|
|
10215
10231
|
return;
|
|
10216
10232
|
}
|
|
10217
10233
|
|
|
@@ -10961,7 +10977,7 @@ function intentCmd(root, sub, ...rest) {
|
|
|
10961
10977
|
log(` total domains: ${Object.keys(catalog.domains).length}`);
|
|
10962
10978
|
log('');
|
|
10963
10979
|
for (const [name, info] of Object.entries(catalog.domains)) {
|
|
10964
|
-
log(grn(` 📦 ${name}`) + dim(` aliases: ${(info.aliases || []).join(', ')}`));
|
|
10980
|
+
log(grn(` 📦 ${_lineSafe(name)}`) + dim(` aliases: ${_lineSafe((info.aliases || []).join(', '))}`));
|
|
10965
10981
|
for (const c of info.components || []) {
|
|
10966
10982
|
log(` • ${c.key.padEnd(12)} ${dim(c.desc)}`);
|
|
10967
10983
|
}
|
|
@@ -11086,9 +11102,16 @@ function _loadNextActionQueue(root) {
|
|
|
11086
11102
|
function _writeNextActionQueue(root, queue) {
|
|
11087
11103
|
try {
|
|
11088
11104
|
mkdirp(path.join(root, '.harness'));
|
|
11105
|
+
_assertStoreParsable(_nextActionQueuePath(root), 'next-action-queue'); // 1.36.114: 손상 스토어 덮어쓰기 거부
|
|
11089
11106
|
writeUtf8(_nextActionQueuePath(root), JSON.stringify({ queue, at: new Date().toISOString() }, null, 2));
|
|
11090
11107
|
return true;
|
|
11091
|
-
} catch {
|
|
11108
|
+
} catch (e) {
|
|
11109
|
+
// 1.36.114: 이 catch 가 손상 신호까지 삼켜, 아무것도 저장되지 않았는데 호출부가 `✓ 추가` 를 찍고 exit 0 이었다
|
|
11110
|
+
// (실측). 조용한 무동작에 성공 표시를 붙이는 것은 조용한 유실보다 나쁘다 — 사용자는 저장됐다고 믿는다.
|
|
11111
|
+
// 손상만 위로 올린다(그 외 I/O 실패는 종전대로 false).
|
|
11112
|
+
if (e && e.code === 'E_STORE_CORRUPT') throw e;
|
|
11113
|
+
return false;
|
|
11114
|
+
}
|
|
11092
11115
|
}
|
|
11093
11116
|
// handoff 에서 제안된 next-action 들을 큐에 자동 저장 (중복 방지: 이미 있는 title 은 skip)
|
|
11094
11117
|
// 1.36.108 (T-0097): 읽기~쓰기를 락으로 직렬화. 이 큐는 handoff 가 자동으로도 채우기 때문에
|
|
@@ -11128,8 +11151,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11128
11151
|
log('');
|
|
11129
11152
|
for (let i = 0; i < state.queue.length; i++) {
|
|
11130
11153
|
const a = state.queue[i];
|
|
11131
|
-
log(` [${i}] ${a.icon || '•'} ${a.title}`);
|
|
11132
|
-
if (a.command) log(` \`${a.command}\``);
|
|
11154
|
+
log(` [${i}] ${a.icon || '•'} ${_lineSafe(a.title)}`); // 1.36.113: 개행이 가짜 큐 항목 줄을 만든다
|
|
11155
|
+
if (a.command) log(` \`${_lineSafe(a.command)}\``);
|
|
11133
11156
|
}
|
|
11134
11157
|
log('');
|
|
11135
11158
|
log(` → 가져오기: leerness next-action take [N] (N 생략 시 최신 [${state.queue.length - 1}])`);
|
|
@@ -11137,12 +11160,15 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11137
11160
|
}
|
|
11138
11161
|
if (sub === 'take') {
|
|
11139
11162
|
const n = rest[0] !== undefined ? Number(rest[0]) : state.queue.length - 1;
|
|
11140
|
-
|
|
11163
|
+
// 1.36.114 (검수 #3, 재현됨): 로더가 손상 파일을 **빈 큐**로 폴백해 "큐 비어있음 — handoff 먼저 실행" 이라고
|
|
11164
|
+
// 오진했다. 사용자는 큐가 비었다고 믿고 handoff 를 다시 돌린다 — 원인(손상)은 끝까지 안 보인다.
|
|
11165
|
+
// 읽기 자체는 종전대로 resilient 하게 두고, **판정 지점에서만** 빈 것과 손상된 것을 가른다.
|
|
11166
|
+
if (state.queue.length === 0) { _assertStoreParsable(_nextActionQueuePath(root), 'next-action-queue'); fail('큐 비어있음 — handoff 먼저 실행'); return process.exit(1); }
|
|
11141
11167
|
if (isNaN(n) || n < 0 || n >= state.queue.length) { fail(`잘못된 index: ${n} (0~${state.queue.length - 1})`); return process.exit(1); }
|
|
11142
11168
|
const action = state.queue[n];
|
|
11143
11169
|
log(`# leerness next-action take [${n}] (1.9.201)`);
|
|
11144
|
-
log(` ${action.icon || '•'} ${action.title}`);
|
|
11145
|
-
if (action.command) log(` \`${action.command}\``);
|
|
11170
|
+
log(` ${action.icon || '•'} ${_lineSafe(action.title)}`);
|
|
11171
|
+
if (action.command) log(` \`${_lineSafe(action.command)}\``);
|
|
11146
11172
|
// task add 자동 호출
|
|
11147
11173
|
try {
|
|
11148
11174
|
const taskTitle = action.title.replace(/^[^\w가-힣]+/, '').slice(0, 100);
|
|
@@ -11159,13 +11185,17 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11159
11185
|
const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
|
|
11160
11186
|
if (taskResult.status === 0) {
|
|
11161
11187
|
const m = (taskResult.stdout || '').match(/T-\d{4,}/);
|
|
11162
|
-
log(` ✓ task 추가: ${m ? m[0] : '?'} — "${taskTitle}"`);
|
|
11163
|
-
if (action.command) log(` 💡 실행 명령: ${action.command}`);
|
|
11188
|
+
log(` ✓ task 추가: ${m ? m[0] : '?'} — "${_lineSafe(taskTitle)}"`); // 1.36.113: 확인 줄도 같은 클래스다
|
|
11189
|
+
if (action.command) log(` 💡 실행 명령: ${_lineSafe(action.command)}`);
|
|
11164
11190
|
} else {
|
|
11165
11191
|
log(` ⚠ task add 실패 (exit ${taskResult.status}) — 수동: leerness task add "${taskTitle}"`);
|
|
11166
11192
|
}
|
|
11167
11193
|
} catch (e) {
|
|
11168
|
-
|
|
11194
|
+
// 1.36.114 (검수 지적, 재현됨): 이 catch 가 손상 신호까지 삼켜 `take` 는 경고만 찍고 **exit 0** 이었다.
|
|
11195
|
+
// `_guardStore` 의 thenable 처리가 정상이어도 예외가 여기서 죽으면 래퍼까지 도달하지 못한다 —
|
|
11196
|
+
// 가드는 예외가 실제로 올라올 때만 가드다. 손상만 위로 올린다(그 외는 종전대로 경고).
|
|
11197
|
+
if (e && e.code === 'E_STORE_CORRUPT') throw e;
|
|
11198
|
+
log(` ⚠ 처리 실패: ${_lineSafe(e.message)}`);
|
|
11169
11199
|
}
|
|
11170
11200
|
return;
|
|
11171
11201
|
}
|
|
@@ -11398,6 +11428,9 @@ function _loadDecisions(root) {
|
|
|
11398
11428
|
// canonical 저장 — decisions.json(canonical) + decisions.md(projection) 동시 기록 (단일 진실소스 write path).
|
|
11399
11429
|
function _saveDecisions(root, decisions) {
|
|
11400
11430
|
const arr = Array.isArray(decisions) ? decisions : [];
|
|
11431
|
+
// 1.36.114 (스토어 전수 대조에서 발견): 손상된 decisions.json 을 빈 배열로 오인해 덮어써 **영구 기억이 사라졌다**.
|
|
11432
|
+
// 표적 사냥(creds 등)으로는 못 봤고, .harness 아래 JSON 스토어를 **열거해 하나씩 손상시키는** 전수 스윕이 잡았다.
|
|
11433
|
+
_assertStoreParsable(decisionsJsonPath(root), 'decisions');
|
|
11401
11434
|
mkdirp(path.dirname(decisionsJsonPath(root)));
|
|
11402
11435
|
writeUtf8(decisionsJsonPath(root), JSON.stringify(arr, null, 2) + '\n');
|
|
11403
11436
|
writeUtf8(decisionsPath(root), _renderDecisionsMd(arr));
|
|
@@ -11450,6 +11483,7 @@ function _loadLessons(root) {
|
|
|
11450
11483
|
}
|
|
11451
11484
|
function _saveLessons(root, lessons) {
|
|
11452
11485
|
const arr = Array.isArray(lessons) ? lessons : [];
|
|
11486
|
+
_assertStoreParsable(lessonsJsonPath(root), 'lessons'); // 1.36.114: decisions 와 같은 클래스 — 손상 위에 덮어써 교훈이 사라졌다
|
|
11453
11487
|
mkdirp(path.dirname(lessonsJsonPath(root)));
|
|
11454
11488
|
writeUtf8(lessonsJsonPath(root), JSON.stringify(arr, null, 2) + '\n');
|
|
11455
11489
|
writeUtf8(lessonsPath(root), _renderLessonsMd(arr));
|
|
@@ -11678,7 +11712,11 @@ function planAdd(root, text) {
|
|
|
11678
11712
|
// 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
|
|
11679
11713
|
const { id, tid } = _withLock(progressPath(root), () => {
|
|
11680
11714
|
const id = nextId(root, 'M');
|
|
11681
|
-
|
|
11715
|
+
// 1.36.113 (방치 표면 사냥): `text` 만 raw 였다 — 같은 문장의 doneWhen 은 1.36.63 검수가 _lineSafe 를 걸었고
|
|
11716
|
+
// 바로 아래 planDrop 은 _cellSafe 를 쓰는데, 여기만 빠져 있었다(수정 클래스 스윕 누락의 전형).
|
|
11717
|
+
// 개행 하나로 `### M-9999. 가짜 마일스톤` 헤더를 plan.md 에 위조할 수 있고, **plan.md 는 handoff 가 읽는다** —
|
|
11718
|
+
// 측정한 9개 사용자텍스트 표면 중 handoff 까지 전파되는 유일한 지점이었다.
|
|
11719
|
+
append(planPath(root), `\n### ${id}. ${_lineSafe(text)}\nStatus: ${status}\nProgress: ${_lineSafe(progress)}%\nDone-When: ${doneWhen}\n\nTasks:\n- [ ] ${_lineSafe(text)}\n`);
|
|
11682
11720
|
const tid = nextId(root, 'T');
|
|
11683
11721
|
upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction });
|
|
11684
11722
|
return { id, tid };
|
|
@@ -12282,8 +12320,8 @@ function lessonListCmd(root, opts = {}) {
|
|
|
12282
12320
|
}
|
|
12283
12321
|
log(`총 ${lessons.length}건${tagFilter ? ` (tag: ${tagFilter})` : ''}${queryFilter ? ` (query: "${queryFilter}")` : ''}:`);
|
|
12284
12322
|
for (const l of lessons) {
|
|
12285
|
-
log(`\n[${l.date || '?'}]${l.tag ? ` #${l.tag}` : ''}`);
|
|
12286
|
-
log(` ${l.text}`);
|
|
12323
|
+
log(`\n[${_lineSafe(l.date || '?')}]${l.tag ? ` #${_lineSafe(l.tag)}` : ''}`);
|
|
12324
|
+
log(` ${_lineSafe(l.text)}`); // 1.36.113: 저장(md)만 막혀 있고 목록 출력은 raw 였다
|
|
12287
12325
|
}
|
|
12288
12326
|
}
|
|
12289
12327
|
|
|
@@ -12371,11 +12409,13 @@ function decisionListCmd(root, opts = {}) {
|
|
|
12371
12409
|
log(`# 🧠 Decisions (1.9.118)${queryFilter ? ` — query: "${queryFilter}"` : ''}\n`);
|
|
12372
12410
|
if (!decisions.length) return ok(queryFilter ? `"${queryFilter}" 매칭 decision 없음` : 'decisions 비어있음');
|
|
12373
12411
|
log(`총 ${decisions.length}건${queryFilter ? ` (query: "${queryFilter}")` : ''}:`);
|
|
12412
|
+
// 1.36.113: 저장(md)은 _lineSafe 로 막혀 있는데 **이 목록 출력만** raw 였다 — 보호가 파일에만 걸려 있었다.
|
|
12413
|
+
// 개행이 있으면 `[2099-01-01] 가짜 결정` 같은 줄이 별도 항목처럼 보인다(사람도 AI 도 구별 못 한다).
|
|
12374
12414
|
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}`);
|
|
12415
|
+
log(`\n[${_lineSafe(d.date || '?')}] ${_lineSafe(d.title)}`);
|
|
12416
|
+
if (d.reason) log(` Reason: ${_lineSafe(d.reason)}`);
|
|
12417
|
+
if (d.alternatives) log(` Alternatives: ${_lineSafe(d.alternatives)}`);
|
|
12418
|
+
if (d.impact) log(` Impact: ${_lineSafe(d.impact)}`);
|
|
12379
12419
|
}
|
|
12380
12420
|
}
|
|
12381
12421
|
|
|
@@ -12479,10 +12519,10 @@ function taskRelink(root) {
|
|
|
12479
12519
|
.map(r => ({ r, score: _jaccard(milestoneTokens, _tokenizeForSim(r.request)) }))
|
|
12480
12520
|
.filter(x => x.score >= minScore)
|
|
12481
12521
|
.sort((a, b) => b.score - a.score);
|
|
12482
|
-
log(`\n${m.id}: ${m.text}`);
|
|
12522
|
+
log(`\n${m.id}: ${_lineSafe(m.text)}`);
|
|
12483
12523
|
if (!candidates.length) {
|
|
12484
12524
|
log(` ⓘ 매칭 후보 없음 (score ≥ ${minScore})`);
|
|
12485
|
-
log(` → 새 task: leerness task add "${m.text}" --status planned --evidence "plan:${m.id}"`);
|
|
12525
|
+
log(` → 새 task: leerness task add "${_lineSafe(m.text)}" --status planned --evidence "plan:${m.id}"`);
|
|
12486
12526
|
continue;
|
|
12487
12527
|
}
|
|
12488
12528
|
const best = candidates[0];
|
|
@@ -14068,7 +14108,7 @@ function handoff(root) {
|
|
|
14068
14108
|
log('');
|
|
14069
14109
|
log(yl4(`## 📥 사용자 요청 자동 완료 가능 (1.9.224, ${delivered.candidates.length}건)`));
|
|
14070
14110
|
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 ? '…' : ''}`);
|
|
14111
|
+
log(` • [${c.id}] v${c.claimedVersion} (${c.deliveredKeyword}) — ${_lineSafe(c.text).slice(0, 70)}${c.text.length > 70 ? '…' : ''}`);
|
|
14072
14112
|
});
|
|
14073
14113
|
if (delivered.candidates.length > 5) {
|
|
14074
14114
|
log(dm4(` ... +${delivered.candidates.length - 5}건 더`));
|
|
@@ -14288,8 +14328,8 @@ function handoff(root) {
|
|
|
14288
14328
|
if (_showAdvice) {
|
|
14289
14329
|
log(grn(`## 🎯 다음 단계 자동 제안 (1.9.194 E축 — 게으름 방지) — 키워드 "${keyword}"`));
|
|
14290
14330
|
for (const a of actions) {
|
|
14291
|
-
log(dim(` ${a.icon} ${a.title}`));
|
|
14292
|
-
if (a.command) log(dim(` \`${a.command}\``));
|
|
14331
|
+
log(dim(` ${a.icon} ${_lineSafe(a.title)}`));
|
|
14332
|
+
if (a.command) log(dim(` \`${_lineSafe(a.command)}\``));
|
|
14293
14333
|
}
|
|
14294
14334
|
}
|
|
14295
14335
|
// 1.9.201: queue 자동 저장 — `leerness next-action take` 로 즉시 task add 가능 (토글과 무관: 상태다)
|
|
@@ -18308,7 +18348,7 @@ function retroCmd(root) {
|
|
|
18308
18348
|
|
|
18309
18349
|
log(`\n## 🧠 최근 결정 (top 5)`);
|
|
18310
18350
|
if (!agg.recentDecisions.length) log(' (없음)');
|
|
18311
|
-
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
|
|
18351
|
+
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${_lineSafe(d.title)}`));
|
|
18312
18352
|
|
|
18313
18353
|
if (agg.durations.length >= 4) {
|
|
18314
18354
|
const mid = Math.floor(agg.durations.length / 2);
|
|
@@ -18673,7 +18713,7 @@ function _brainstormWorkspace(rootBase, topic) {
|
|
|
18673
18713
|
log(`\n## ${path.basename(p)} (${n}건)`);
|
|
18674
18714
|
if (h.decisions.length) {
|
|
18675
18715
|
log(` 🧠 결정 (${h.decisions.length})`);
|
|
18676
|
-
h.decisions.slice(0, 3).forEach(d => log(` - decisions.md:${d.line || '?'} — ${d.title}`));
|
|
18716
|
+
h.decisions.slice(0, 3).forEach(d => log(` - decisions.md:${d.line || '?'} — ${_lineSafe(d.title)}`));
|
|
18677
18717
|
}
|
|
18678
18718
|
if (h.skills.length) {
|
|
18679
18719
|
log(` 📚 스킬 (${h.skills.length})`);
|
|
@@ -18769,7 +18809,7 @@ function brainstormCmd(root, topic) {
|
|
|
18769
18809
|
// 1.9.15: 모든 출력에 출처 파일:라인 표시
|
|
18770
18810
|
if (hits.decisions.length) {
|
|
18771
18811
|
log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
|
|
18772
|
-
hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${d.title}`));
|
|
18812
|
+
hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${_lineSafe(d.title)}`));
|
|
18773
18813
|
}
|
|
18774
18814
|
if (hits.skills.length) {
|
|
18775
18815
|
log(`\n## 📚 관련 스킬 (${hits.skills.length}) — 시작 전 \`skill info <id>\` 권장`);
|
|
@@ -20439,7 +20479,7 @@ function reuseFind(root, query) {
|
|
|
20439
20479
|
log(`# reuse find: "${query}"`);
|
|
20440
20480
|
if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
|
|
20441
20481
|
log(`${matches.length}개 후보:`);
|
|
20442
|
-
for (const m of matches.slice(0, _parseLimit(arg('--limit', '20'), 20))) log(`- ${m.source}:${m.line} ${m.text}`);
|
|
20482
|
+
for (const m of matches.slice(0, _parseLimit(arg('--limit', '20'), 20))) log(`- ${m.source}:${m.line} ${_lineSafe(m.text)}`);
|
|
20443
20483
|
log(`\n💡 새로 만들기 전에 위 자원을 재사용/확장 가능한지 확인하세요.`);
|
|
20444
20484
|
}
|
|
20445
20485
|
|
|
@@ -22400,6 +22440,7 @@ function _writePermissionsPreset(root, mode) {
|
|
|
22400
22440
|
preset.generatedAt = new Date().toISOString();
|
|
22401
22441
|
preset.leernessVersion = VERSION;
|
|
22402
22442
|
mkdirp(path.dirname(_permissionsPath(root)));
|
|
22443
|
+
_assertStoreParsable(_permissionsPath(root), 'agent-permissions'); // 1.36.114: 손상 스토어 덮어쓰기 거부(1.36.28 클래스 누락 지점)
|
|
22403
22444
|
writeUtf8(_permissionsPath(root), JSON.stringify(preset, null, 2) + '\n');
|
|
22404
22445
|
return preset;
|
|
22405
22446
|
}
|
|
@@ -23438,7 +23479,8 @@ function briefCmd(root, sub) {
|
|
|
23438
23479
|
const brief = _loadBrief(root);
|
|
23439
23480
|
if (has('--json')) { log(JSON.stringify(brief, null, 2)); return; }
|
|
23440
23481
|
log(cy(`# leerness brief — ${brief.project}`));
|
|
23441
|
-
|
|
23482
|
+
// 1.36.113: brief 값은 사용자가 여러 줄로 넣을 수 있고(`brief set --intro`), 그대로 찍으면 둘째 줄이 별도 항목처럼 보인다
|
|
23483
|
+
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
23484
|
log('');
|
|
23443
23485
|
log(dm(` 채움 ${_briefFilled(brief)}/${_BRIEF_FIELDS.length} · 설정: leerness brief set --intro "..." · 복사용: leerness brief export`));
|
|
23444
23486
|
return;
|
|
@@ -23503,8 +23545,8 @@ function contextCmd(root, opts = {}) {
|
|
|
23503
23545
|
const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
23504
23546
|
const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
23505
23547
|
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)`));
|
|
23548
|
+
if (intent) log(` 🎯 의도: ${_lineSafe(intent)}`);
|
|
23549
|
+
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
23550
|
log('');
|
|
23509
23551
|
if (ctx.currentTask) {
|
|
23510
23552
|
log(gr(`▶ 현재 작업: ${ctx.currentTask.id} — ${ctx.currentTask.request}`));
|
|
@@ -23512,12 +23554,12 @@ function contextCmd(root, opts = {}) {
|
|
|
23512
23554
|
} else log(dm('▶ 현재 진행 중 작업 없음'));
|
|
23513
23555
|
log('');
|
|
23514
23556
|
log(`📥 미답 요청: ${ctx.openRequests.count}건`);
|
|
23515
|
-
ctx.openRequests.items.forEach(r => log(dm(` • [${r.id}] ${r.text}`)));
|
|
23557
|
+
ctx.openRequests.items.forEach(r => log(dm(` • [${r.id}] ${_lineSafe(r.text)}`))); // 1.36.113: 개행이 가짜 항목 줄을 만든다
|
|
23516
23558
|
log('');
|
|
23517
23559
|
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 : ''}`))); }
|
|
23560
|
+
if (recentDecisions.length) { log(''); log('🗂 최근 결정:'); recentDecisions.forEach(d => log(dm(` • ${d.date || '?'} — ${_lineSafe(d.title)}`))); }
|
|
23561
|
+
if (ctx.activeRules.length) { log(''); log('⚡ 활성 룰:'); ctx.activeRules.forEach(r => log(dm(` • [${r.id}] (${r.trigger}) ${_lineSafe(r.rule)}`))); }
|
|
23562
|
+
if (nextActions.length) { log(''); log('👉 다음 액션:'); nextActions.forEach(a => log(dm(` • ${_lineSafe(a.title)}${a.command ? ' → ' + _lineSafe(a.command) : ''}`))); }
|
|
23521
23563
|
return ctx;
|
|
23522
23564
|
}
|
|
23523
23565
|
function stateCmd(root, sub, ...args) {
|
|
@@ -24816,6 +24858,11 @@ function _readCredentials(root) {
|
|
|
24816
24858
|
}
|
|
24817
24859
|
function _writeCredentials(root, data) {
|
|
24818
24860
|
const p = _credentialsPath(root);
|
|
24861
|
+
// 1.36.114 (방치 명령 사냥): 손상된 스토어를 "빈 값" 으로 오인해 덮어쓰던 fail-open — 1.36.28 이 teams/
|
|
24862
|
+
// platform-constraints 에서 고친 클래스인데 여기는 빠져 있었다. 실측: 파일을 잘라 두고 `creds register` 하면
|
|
24863
|
+
// exit 0 으로 성공하며 **기존 서비스 등록이 사라졌다**(_readCredentials 의 catch 가 {} 를 돌려주기 때문).
|
|
24864
|
+
// 술어는 이미 있으니 재구현하지 않고 공유한다.
|
|
24865
|
+
_assertStoreParsable(p, 'credentials');
|
|
24819
24866
|
mkdirp(path.dirname(p));
|
|
24820
24867
|
writeUtf8(p, JSON.stringify(data, null, 2) + '\n');
|
|
24821
24868
|
// 1.9.147: gitignore + npmignore 자동 보강 (보안)
|
|
@@ -24835,14 +24882,16 @@ function credsListCmd(root) {
|
|
|
24835
24882
|
if (has('--json')) { log(JSON.stringify(j, null, 2)); return; }
|
|
24836
24883
|
log(`# leerness creds list (1.9.147)`);
|
|
24837
24884
|
const services = Object.entries(j.services || {});
|
|
24838
|
-
|
|
24885
|
+
// 1.36.114: "없음" 과 "손상" 을 가른다 — 손상인데 '없음' 이라 말하면 사용자는 등록이 지워졌다고 오해한다.
|
|
24886
|
+
// 읽기 자체는 resilient 하게 두고 **판정 지점에서만** 구분한다(check/refresh 와 같은 방식).
|
|
24887
|
+
if (!services.length) { _assertStoreParsable(_credentialsPath(root), 'credentials'); log('(등록된 자격증명 없음 — leerness creds register <service> --env-var <NAME>)'); return; }
|
|
24839
24888
|
log(`총 ${services.length}개 서비스 (값 미저장 — env-ref 만)`);
|
|
24840
24889
|
for (const [name, meta] of services) {
|
|
24841
24890
|
const present = meta.envVars.every(v => process.env[v] !== undefined && process.env[v] !== '');
|
|
24842
24891
|
const last = meta.lastRefreshed ? new Date(meta.lastRefreshed) : null;
|
|
24843
24892
|
const ageDays = last ? Math.floor((Date.now() - last.getTime()) / 86400000) : null;
|
|
24844
24893
|
const ageWarn = (meta.tokenLifetimeHours && last && (Date.now() - last.getTime()) > meta.tokenLifetimeHours * 3600 * 1000);
|
|
24845
|
-
log(` ${name}: env=${meta.envVars.join(',')} · ${present ? '✓ 환경변수 있음' : '⚠ 미설정'}${ageDays !== null ? ` · ${ageDays}일 전 refresh${ageWarn ? ' (만료 가능)' : ''}` : ''}`);
|
|
24894
|
+
log(` ${_lineSafe(name)}: env=${_lineSafe(meta.envVars.join(','))} · ${present ? '✓ 환경변수 있음' : '⚠ 미설정'}${ageDays !== null ? ` · ${ageDays}일 전 refresh${ageWarn ? ' (만료 가능)' : ''}` : ''}`);
|
|
24846
24895
|
if (meta.deployCommand) log(` deploy: ${meta.deployCommand}`);
|
|
24847
24896
|
}
|
|
24848
24897
|
}
|
|
@@ -24874,7 +24923,8 @@ function credsCheckCmd(root, service) {
|
|
|
24874
24923
|
const j = _readCredentials(root);
|
|
24875
24924
|
const result = { service: service || null, services: {}, ok: true };
|
|
24876
24925
|
const targets = service ? (j.services[service] ? { [service]: j.services[service] } : {}) : (j.services || {});
|
|
24877
|
-
|
|
24926
|
+
// 1.36.114 (검수 #2, 재현됨): 손상 파일을 빈 registry 로 폴백해 "등록된 서비스 없음" 이라 오진했다 — 판정 지점에서 가른다
|
|
24927
|
+
if (!Object.keys(targets).length) { _assertStoreParsable(_credentialsPath(root), 'credentials'); failJson(has('--json'), 'no_service', `등록된 서비스 없음${service ? ` (${service})` : ''}`); return; } // 1.9.404 (UR-0105 잔여): --json 에러 구조화
|
|
24878
24928
|
for (const [name, meta] of Object.entries(targets)) {
|
|
24879
24929
|
// 1.36.34 (codex 3차 #9): 손상 레코드(null/비객체) → raw TypeError 크래시, 파싱 불가 날짜(NaN) → false-ready. 구조화 판정.
|
|
24880
24930
|
if (!meta || typeof meta !== 'object' || !Array.isArray(meta.envVars)) {
|
|
@@ -24904,7 +24954,7 @@ function credsRefreshTimestampCmd(root, service) {
|
|
|
24904
24954
|
root = absRoot(root || process.cwd());
|
|
24905
24955
|
if (!service) return fail('service 이름 필요');
|
|
24906
24956
|
const j = _readCredentials(root);
|
|
24907
|
-
if (!j.services[service]) return fail(`등록된 서비스 없음: ${service} — leerness creds register 먼저`);
|
|
24957
|
+
if (!j.services[service]) { _assertStoreParsable(_credentialsPath(root), 'credentials'); return fail(`등록된 서비스 없음: ${service} — leerness creds register 먼저`); } // 1.36.114: 손상과 미등록을 가른다
|
|
24908
24958
|
j.services[service].lastRefreshed = new Date().toISOString();
|
|
24909
24959
|
_writeCredentials(root, j);
|
|
24910
24960
|
ok(`creds refreshed: ${service} · lastRefreshed=${j.services[service].lastRefreshed}`);
|
|
@@ -24938,7 +24988,7 @@ function incidentListCmd(root) {
|
|
|
24938
24988
|
try {
|
|
24939
24989
|
const j = JSON.parse(read(path.join(dir, f)));
|
|
24940
24990
|
const e = j.payload?.error || j.payload?.message || '(no description)';
|
|
24941
|
-
log(` ${j.id} · ${String(e).slice(0, 80)}`);
|
|
24991
|
+
log(` ${j.id} · ${_lineSafe(String(e)).slice(0, 80)}`);
|
|
24942
24992
|
} catch {}
|
|
24943
24993
|
}
|
|
24944
24994
|
}
|
|
@@ -24966,7 +25016,7 @@ async function incidentHandleCmd(root, id) {
|
|
|
24966
25016
|
log(`incident: ${j.id} · permission mode: ${p.mode || 'basic'}`);
|
|
24967
25017
|
const err = j.payload?.error || j.payload?.message || '';
|
|
24968
25018
|
const stack = j.payload?.stack || '';
|
|
24969
|
-
log(`error: ${String(err).slice(0, 200)}`);
|
|
25019
|
+
log(`error: ${_lineSafe(String(err)).slice(0, 200)}`);
|
|
24970
25020
|
if (stack) log(`stack head:\n${String(stack).split('\n').slice(0, 4).join('\n')}`);
|
|
24971
25021
|
// (1) feature impact 자동 회수 — error 키워드 매칭
|
|
24972
25022
|
try {
|
|
@@ -25005,7 +25055,9 @@ async function incidentHandleCmd(root, id) {
|
|
|
25005
25055
|
j.permissionMode = p.mode || 'basic';
|
|
25006
25056
|
writeUtf8(fp, JSON.stringify(j, null, 2) + '\n');
|
|
25007
25057
|
ok(`incident handled: ${j.id} (분석/회수 완료)`);
|
|
25008
|
-
|
|
25058
|
+
// 1.36.113: 이 줄은 **복붙해서 실행하라고** 주는 명령이다 — 외부 webhook 이 넣은 개행이 여기서 갈리면
|
|
25059
|
+
// 따옴표가 닫히지 않은 채 둘째 줄이 별도 명령처럼 보인다. 인용 대상은 반드시 한 줄로 접는다.
|
|
25060
|
+
log(` → 후속: leerness agent "fix: ${_lineSafe(String(err)).slice(0, 80)}" / leerness verify-code . / leerness deploy auto`);
|
|
25009
25061
|
}
|
|
25010
25062
|
|
|
25011
25063
|
// ---- (3) Webhook Listener ----
|
|
@@ -25847,7 +25899,7 @@ function lspCmd(root, sub, ...args) {
|
|
|
25847
25899
|
} else {
|
|
25848
25900
|
log(`# leerness lsp references (1.9.167)`);
|
|
25849
25901
|
log(`symbol: "${name}" · ${refs.length} references · ${dt}ms`);
|
|
25850
|
-
refs.slice(0, 30).forEach(r => log(` ${r.file}:${r.line} ${r.text}`));
|
|
25902
|
+
refs.slice(0, 30).forEach(r => log(` ${r.file}:${r.line} ${_lineSafe(r.text)}`));
|
|
25851
25903
|
if (refs.length > 30) log(` ... ${refs.length - 30} more`);
|
|
25852
25904
|
}
|
|
25853
25905
|
try { _recordRun(root, { kind: 'lsp_references', name, count: refs.length, durationMs: dt, ok: true }); } catch {}
|
|
@@ -26564,17 +26616,17 @@ async function main() {
|
|
|
26564
26616
|
if (cmd === 'env' && args[1] === 'detect') return envDetectCmd(args[2] || arg('--path', process.cwd()));
|
|
26565
26617
|
// 1.9.146: agent 권한 시스템 + CLI 에이전트 모드 (사용자 명시 요청 #4, #5)
|
|
26566
26618
|
if (cmd === 'permissions' && args[1] === 'list') return permissionsListCmd(arg('--path', process.cwd()));
|
|
26567
|
-
if (cmd === 'permissions' && args[1] === 'set') return permissionsSetCmd(arg('--path', process.cwd()), args[2]);
|
|
26619
|
+
if (cmd === 'permissions' && args[1] === 'set') return _guardStore(has('--json'), () => permissionsSetCmd(arg('--path', process.cwd()), args[2]));
|
|
26568
26620
|
if (cmd === 'agent') return agentCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('--')).join(' '));
|
|
26569
26621
|
// 1.9.147: 자동 유지보수 시스템 (사용자 명시 요청)
|
|
26570
26622
|
if (cmd === 'webhook' && args[1] === 'serve') return webhookServeCmd(arg('--path', process.cwd()));
|
|
26571
26623
|
if (cmd === 'incident' && args[1] === 'list') return incidentListCmd(arg('--path', process.cwd()));
|
|
26572
26624
|
if (cmd === 'incident' && args[1] === 'show') return incidentShowCmd(arg('--path', process.cwd()), args[2]);
|
|
26573
26625
|
if (cmd === 'incident' && args[1] === 'handle') return incidentHandleCmd(arg('--path', process.cwd()), args[2]);
|
|
26574
|
-
if (cmd === 'creds' && args[1] === 'list') return credsListCmd(arg('--path', process.cwd()));
|
|
26575
|
-
if (cmd === 'creds' && args[1] === 'register') return credsRegisterCmd(arg('--path', process.cwd()), args[2]);
|
|
26576
|
-
if (cmd === 'creds' && args[1] === 'check') return credsCheckCmd(arg('--path', process.cwd()), args[2]);
|
|
26577
|
-
if (cmd === 'creds' && args[1] === 'refresh') return credsRefreshTimestampCmd(arg('--path', process.cwd()), args[2]);
|
|
26626
|
+
if (cmd === 'creds' && args[1] === 'list') return _guardStore(has('--json'), () => credsListCmd(arg('--path', process.cwd())));
|
|
26627
|
+
if (cmd === 'creds' && args[1] === 'register') return _guardStore(has('--json'), () => credsRegisterCmd(arg('--path', process.cwd()), args[2]));
|
|
26628
|
+
if (cmd === 'creds' && args[1] === 'check') return _guardStore(has('--json'), () => credsCheckCmd(arg('--path', process.cwd()), args[2]));
|
|
26629
|
+
if (cmd === 'creds' && args[1] === 'refresh') return _guardStore(has('--json'), () => credsRefreshTimestampCmd(arg('--path', process.cwd()), args[2]));
|
|
26578
26630
|
if (cmd === 'deploy' && args[1] === 'auto') return deployAutoCmd(arg('--path', process.cwd()), args[2]);
|
|
26579
26631
|
// 1.9.149: observability lite + runs list/show
|
|
26580
26632
|
if (cmd === 'runs' && args[1] === 'list') return runsListCmd(absRoot(_resolveRoot(args[2]))); // 1.9.412 (UR-0100): positional path 지원
|
|
@@ -26630,7 +26682,7 @@ async function main() {
|
|
|
26630
26682
|
if (cmd === 'roadmap' && args[1] === 'auto') return roadmapAutoCmd(arg('--path', process.cwd()), args[2]);
|
|
26631
26683
|
if (cmd === 'roadmap') return roadmapCmd(arg('--path', args[1] || process.cwd()));
|
|
26632
26684
|
// 1.9.201: next-action queue CLI (E축 9.5→10)
|
|
26633
|
-
if (cmd === 'next-action') return nextActionCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
|
|
26685
|
+
if (cmd === 'next-action') return _guardStore(has('--json'), () => nextActionCmd(arg('--path', process.cwd()), args[1], ...args.slice(2)));
|
|
26634
26686
|
// 1.9.203: leerness resume — auto-resume-plan 읽고 다음 라운드 즉시 안내 (사용자 명시)
|
|
26635
26687
|
if (cmd === 'resume') return resumeCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
|
|
26636
26688
|
// 1.9.207: leerness requests <audit|add|list|complete|drop> — 사용자 요청 누락 확인 절차 (사용자 명시)
|
|
@@ -26796,7 +26848,7 @@ async function main() {
|
|
|
26796
26848
|
// 1.9.209: leerness pre-wake-audit — sleep 전 sub-agent audit (사용자 명시)
|
|
26797
26849
|
if (cmd === 'pre-wake-audit') return preWakeAuditCmd(arg('--path', process.cwd()), args[1]);
|
|
26798
26850
|
// 1.9.210: leerness wakeup-interval <get|set|auto|history|record> — adaptive interval (사용자 명시)
|
|
26799
|
-
if (cmd === 'wakeup-interval') return wakeupIntervalCmd(arg('--path', process.cwd()), args[1], args[2]);
|
|
26851
|
+
if (cmd === 'wakeup-interval') return _guardStore(has('--json'), () => wakeupIntervalCmd(arg('--path', process.cwd()), args[1], args[2]));
|
|
26800
26852
|
// 1.9.211: leerness workspace-dir <get|guide> — 현재 워크스페이스 디렉토리 / AI 참조 가이드 (사용자 명시)
|
|
26801
26853
|
if (cmd === 'workspace-dir') return workspaceDirCmd(arg('--path', process.cwd()), args[1]);
|
|
26802
26854
|
// 1.36.79 (도그푸딩 P1-C): positional 경로(`parent detect <path>`)를 무시하고 cwd 를 분석해 자신만만한 오답(exit 0)을 내던 것
|
|
@@ -26945,7 +26997,7 @@ async function main() {
|
|
|
26945
26997
|
const _text = textParts.join(' ');
|
|
26946
26998
|
// 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 lessonSave 내부 fail() 가 평문 출력.
|
|
26947
26999
|
if (!_text) { failJson(has('--json'), 'empty_text', 'lesson save "<text>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
|
|
26948
|
-
return lessonSave(root, _text);
|
|
27000
|
+
return _guardStore(has('--json'), () => lessonSave(root, _text)); // 1.36.114: 손상 스토어 사유를 구조화(store_corrupt)
|
|
26949
27001
|
}
|
|
26950
27002
|
if (sub === 'list') {
|
|
26951
27003
|
return lessonListCmd(root, { json: has('--json') });
|
|
@@ -26970,7 +27022,7 @@ async function main() {
|
|
|
26970
27022
|
// 1.9.351 (UR-0064) → 1.9.416 (UR-0122): 공유 헬퍼 _parseAddTitle 로 단일화(flag/경로 break) + 빈 입력 거부
|
|
26971
27023
|
const title = _parseAddTitle(args, 2);
|
|
26972
27024
|
if (!title) { failJson(has('--json'), 'empty_title', 'decision add "<제목>" 필요'); return process.exit(process.exitCode || 1); }
|
|
26973
|
-
return decisionAdd(root, title);
|
|
27025
|
+
return _guardStore(has('--json'), () => decisionAdd(root, title)); // 1.36.114: 손상 스토어 사유를 구조화(store_corrupt)
|
|
26974
27026
|
}
|
|
26975
27027
|
if (sub === 'list') {
|
|
26976
27028
|
return decisionListCmd(absRoot(_resolveRoot(args[2])), { json: has('--json') }); // 1.9.412 (UR-0100): positional path 지원(add 의 args[2]=title 와 분리)
|
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.114",
|
|
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,313 @@ 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
|
+
|
|
10969
|
+
// ── 1.36.114 블록 R: **방치된 명령**에 남아 있던 두 기지(旣知) 클래스를 고정한다.
|
|
10970
|
+
// 왜 여기까지 왔나: e2e 실행 커버리지에서 17개 명령이 한 번도 안 돌고 있었다. 그 방치 구역에는
|
|
10971
|
+
// leerness 가 **다른 곳에서는 이미 고친** 결함이 그대로 남아 있었다.
|
|
10972
|
+
// 클래스 A(1.36.28): 손상된 JSON 스토어를 "빈 값" 으로 오인해 덮어써 기존 데이터를 잃는다.
|
|
10973
|
+
// 실측 유실: credentials.local.json · agent-permissions.json · wakeup-history.json · next-action-queue.json
|
|
10974
|
+
// (보호돼 있던 대조군: user-requests.json · previews.json · toggles.json — 술어 `_assertStoreParsable` 는 이미 있었다.)
|
|
10975
|
+
// 클래스 B(1.36.113): 사용자 텍스트의 개행이 목록에서 가짜 항목을 만든다. creds·constraints 가 지난 라운드 목록 밖이었다.
|
|
10976
|
+
// ⚠ 이 라운드가 만든 회귀도 여기서 잡았다: 손상 시 저장은 막혔는데 명령이 `✓ 추가` 를 찍고 exit 0 이었다.
|
|
10977
|
+
// 조용한 무동작 + 성공 표시는 조용한 유실보다 나쁘다 — 사용자는 저장됐다고 믿는다. 그래서 **세 가지를 함께** 단언한다:
|
|
10978
|
+
// 안 쓴다 · 성공이라 말하지 않는다 · 정상 스토어에서는 오차단하지 않는다.
|
|
10979
|
+
{
|
|
10980
|
+
total++;
|
|
10981
|
+
let ok = false; const dbg = {};
|
|
10982
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-neg114-'));
|
|
10983
|
+
const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_NO_PROMPT: '1' });
|
|
10984
|
+
try {
|
|
10985
|
+
let seq = 0;
|
|
10986
|
+
const mk = () => {
|
|
10987
|
+
const d = path.join(sb, 'p' + (++seq)); fs.mkdirSync(d, { recursive: true });
|
|
10988
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
10989
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10990
|
+
return d;
|
|
10991
|
+
};
|
|
10992
|
+
const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10993
|
+
const bad = [];
|
|
10994
|
+
|
|
10995
|
+
// ── 클래스 A: 손상 스토어. 대조군(이미 보호되던 표면)을 **같은 표에** 넣어야 판정이 의미를 갖는다.
|
|
10996
|
+
const STORES = [
|
|
10997
|
+
['creds', 'credentials.local.json', ['creds', 'register', 'aaa', '--env-var', 'A_KEY'], ['creds', 'register', 'bbb', '--env-var', 'B_KEY']],
|
|
10998
|
+
['permissions', 'agent-permissions.json', ['permissions', 'set', 'extended'], ['permissions', 'set', 'basic']],
|
|
10999
|
+
['wakeup-interval', 'wakeup-history.json', ['wakeup-interval', 'set', '900'], ['wakeup-interval', 'set', '1200']],
|
|
11000
|
+
['next-action', 'next-action-queue.json', ['next-action', 'add', 'aaa'], ['next-action', 'add', 'bbb']],
|
|
11001
|
+
['requests(대조)', 'user-requests.json', ['requests', 'add', 'aaa'], ['requests', 'add', 'bbb']],
|
|
11002
|
+
['preview(대조)', 'previews.json', ['preview', 'add', 'aaa'], ['preview', 'add', 'bbb']],
|
|
11003
|
+
// 대조군 3종을 다 넣는다 — toggles 가 빠져 있어 "보호 대조군 유지" 주장이 2/3 만 증명됐다(검수 지적).
|
|
11004
|
+
['toggle(대조)', 'toggles.json', ['toggle', 'set', 'gate', 'off'], ['toggle', 'set', 'lens', 'off']],
|
|
11005
|
+
];
|
|
11006
|
+
let setupOk = 0;
|
|
11007
|
+
for (const [name, file, first, second] of STORES) {
|
|
11008
|
+
const d = mk();
|
|
11009
|
+
const r1 = R(d, first);
|
|
11010
|
+
const fp = path.join(d, '.harness', file);
|
|
11011
|
+
if (r1.status !== 0 || !fs.existsSync(fp)) { bad.push(`${name}:셋업실패`); continue; } // 조용한 셋업 실패가 '통과' 로 둔갑하지 못하게
|
|
11012
|
+
setupOk++;
|
|
11013
|
+
fs.writeFileSync(fp, '{ "broken": ');
|
|
11014
|
+
const before = fs.readFileSync(fp, 'utf8');
|
|
11015
|
+
const r2 = R(d, second);
|
|
11016
|
+
const out = String(r2.stdout || '') + String(r2.stderr || '');
|
|
11017
|
+
if (fs.readFileSync(fp, 'utf8') !== before) bad.push(`${name}:손상위덮어씀`);
|
|
11018
|
+
if (r2.status === 0 || /^\s*✓/m.test(out)) bad.push(`${name}:성공이라말함`); // 이 라운드가 낸 회귀를 고정
|
|
11019
|
+
if (/\n\s+at .*:\d+:\d+/.test(out)) bad.push(`${name}:스택노출`);
|
|
11020
|
+
// 기계 계약 — `--json` 은 **순수 JSON**(stdout 에 사람용 줄이 섞이면 JSON.parse 가 깨진다) + ok:false + exit≠0.
|
|
11021
|
+
// 이 단언이 없어서 `_guardStore` 를 떼는 변이가 살아남았다 — 그 래퍼는 async 명령에서 아예 무력이었고,
|
|
11022
|
+
// 실측에서 `next-action add --json` 이 `✗` 줄을 JSON 앞에 흘리고 있었다.
|
|
11023
|
+
// ⚠ 사유 코드는 표면마다 다르다(store_corrupt / store_invalid / error). 통일은 기존 두 표면의 계약을
|
|
11024
|
+
// 바꾸는 일이라 이 라운드에서 하지 않는다 — **이번에 고친 4종만** store_corrupt 를 요구하고, 나머지는 형태만 본다.
|
|
11025
|
+
fs.writeFileSync(fp, '{ "broken": ');
|
|
11026
|
+
const brokenJ = fs.readFileSync(fp, 'utf8');
|
|
11027
|
+
const rj = R(d, second.concat(['--json']));
|
|
11028
|
+
let parsed = null; try { parsed = JSON.parse(String(rj.stdout || '').trim()); } catch {}
|
|
11029
|
+
if (!parsed) bad.push(`${name}:json_stdout오염`);
|
|
11030
|
+
else {
|
|
11031
|
+
if (parsed.ok !== false) bad.push(`${name}:json_ok아님`);
|
|
11032
|
+
if (!/대조/.test(name) && parsed.code !== 'store_corrupt') bad.push(`${name}:json_코드(${parsed.code})`);
|
|
11033
|
+
}
|
|
11034
|
+
if (rj.status === 0) bad.push(`${name}:json_exit0`);
|
|
11035
|
+
// ⚠ JSON 경로에서도 **파일이 그대로인지** 봐야 한다. 검수 지적: 종전엔 파싱·ok·exit 만 봐서
|
|
11036
|
+
// "덮어쓰고 나서 {ok:false} 를 돌려주는" 변이가 그대로 통과했다. stderr 스택도 함께 본다.
|
|
11037
|
+
if (fs.readFileSync(fp, 'utf8') !== brokenJ) bad.push(`${name}:json경로_손상위덮어씀`);
|
|
11038
|
+
if (/\n\s+at .*:\d+:\d+/.test(String(rj.stderr || ''))) bad.push(`${name}:json_stderr스택`);
|
|
11039
|
+
// 오차단 방지 — 정상 스토어에서는 성공해야 하고, **실제로 저장돼야** 한다.
|
|
11040
|
+
// 종전엔 exit code 만 봐서 조용한 no-op(성공 코드 + 성공 메시지, 저장 없음)이 통과했다(검수 지적).
|
|
11041
|
+
const d2 = mk(); R(d2, first);
|
|
11042
|
+
const fp2 = path.join(d2, '.harness', file);
|
|
11043
|
+
const pre = fs.existsSync(fp2) ? fs.readFileSync(fp2, 'utf8') : null;
|
|
11044
|
+
if (R(d2, second).status !== 0) bad.push(`${name}:정상스토어_오차단`);
|
|
11045
|
+
const post = fs.existsSync(fp2) ? fs.readFileSync(fp2, 'utf8') : null;
|
|
11046
|
+
if (pre === null || post === null || post === pre) bad.push(`${name}:정상스토어_저장안됨`);
|
|
11047
|
+
}
|
|
11048
|
+
dbg.setupOk = setupOk;
|
|
11049
|
+
if (setupOk !== STORES.length) bad.push(`셋업 ${setupOk}/${STORES.length}`);
|
|
11050
|
+
|
|
11051
|
+
// ── 클래스 B: 개행 위조. 지난 라운드 9표면 목록 **밖**이었던 두 곳.
|
|
11052
|
+
for (const [name, add, list] of [
|
|
11053
|
+
['creds', (d, t) => R(d, ['creds', 'register', t, '--env-var', 'X_KEY']), (d) => R(d, ['creds', 'list'])],
|
|
11054
|
+
['constraints', (d, t) => R(d, ['constraints', 'add', t, '--constraint', 'k:v']), (d) => R(d, ['constraints', 'list'])],
|
|
11055
|
+
// ⚠ ID 에만 개행을 넣으면 **값 쪽 필드**를 놓친다 — 검수가 `--constraint` 의 kind/detail 이 raw 임을 지적했다.
|
|
11056
|
+
// 같은 명령이라도 인자마다 따로 확인해야 한다(1.36.113 에서 plan 의 --progress 를 놓친 것과 같은 형태).
|
|
11057
|
+
['constraints(detail)', (d, t) => R(d, ['constraints', 'add', 'PLAT', '--constraint', `auth:${t}`]), (d) => R(d, ['constraints', 'list'])],
|
|
11058
|
+
]) {
|
|
11059
|
+
const d = mk();
|
|
11060
|
+
if (add(d, 'real ZR4A\n fake ZR4F · 등록됨').status !== 0) { bad.push(`${name}:주입add실패`); continue; }
|
|
11061
|
+
const o = String(list(d).stdout || '');
|
|
11062
|
+
if (!o.includes('ZR4A')) bad.push(`${name}:항목미노출`); // 계측 판별력
|
|
11063
|
+
if (o.split('\n').some(l => l.includes('ZR4F') && !l.includes('ZR4A'))) bad.push(`${name}:개행위조`);
|
|
11064
|
+
}
|
|
11065
|
+
|
|
11066
|
+
// ── 전수 대조: `.harness` 아래 **사용자 데이터 JSON 스토어 전부**를 하나씩 손상시키고 변경 명령을 돌린다.
|
|
11067
|
+
// 표적 사냥으로는 4종만 봤는데, 이 열거식 스윕이 decisions.json · lessons.json 을 더 찾았다
|
|
11068
|
+
// (leerness 가 내세우는 '영구 메모리' 표면이 손상 위에 덮어써져 사라지고 있었다).
|
|
11069
|
+
// 목록을 박아 두면 **가드 없는 새 스토어가 추가될 때** 실패한다 — 개별 발견이 아니라 목록을 지키는 가드다.
|
|
11070
|
+
{
|
|
11071
|
+
const SEED = [['requests', 'add', 'seed'], ['preview', 'add', 'seed'], ['toggle', 'set', 'gate', 'off'],
|
|
11072
|
+
['creds', 'register', 'seed', '--env-var', 'K'], ['permissions', 'set', 'extended'], ['wakeup-interval', 'set', '900'],
|
|
11073
|
+
['next-action', 'add', 'seed'], ['constraints', 'add', 'seed', '--constraint', 'k:v'],
|
|
11074
|
+
['decision', 'add', 'seed', '--why', 'r'], ['lesson', 'save', 'seed']];
|
|
11075
|
+
const MUT = [['requests', 'add', 'x2'], ['preview', 'add', 'x2'], ['toggle', 'set', 'lens', 'off'],
|
|
11076
|
+
['creds', 'register', 'x2', '--env-var', 'K2'], ['permissions', 'set', 'basic'], ['wakeup-interval', 'set', '1200'],
|
|
11077
|
+
['next-action', 'add', 'x2'], ['constraints', 'add', 'x2', '--constraint', 'k:v'],
|
|
11078
|
+
['decision', 'add', 'x2', '--why', 'r'], ['lesson', 'save', 'x2'], ['task', 'add', 'x2'], ['plan', 'add', 'x2']];
|
|
11079
|
+
const DERIVED = new Set(['manifest.json', 'skills-lock.json', 'leerness-config.json', 'environment.json']);
|
|
11080
|
+
const seeded = () => { const d = mk(); for (const a of SEED) R(d, a); return d; };
|
|
11081
|
+
const base = seeded();
|
|
11082
|
+
const stores = [];
|
|
11083
|
+
(function w(x, rel) { for (const e of fs.readdirSync(x, { withFileTypes: true })) {
|
|
11084
|
+
const p = path.join(x, e.name), r = rel ? rel + '/' + e.name : e.name;
|
|
11085
|
+
if (e.isDirectory()) { if (!/cache|archive|skills$/.test(e.name)) w(p, r); }
|
|
11086
|
+
else if (/\.json$/.test(e.name)) stores.push(r); } })(path.join(base, '.harness'), '');
|
|
11087
|
+
const userStores = stores.filter(f => !DERIVED.has(path.basename(f)));
|
|
11088
|
+
dbg.stores = userStores.length;
|
|
11089
|
+
// 계측 판별력 — 스토어가 갑자기 줄면(시드 실패 등) "유실 0" 이 공허해진다. 실측 9종 이상.
|
|
11090
|
+
if (userStores.length < 9) bad.push(`스토어열거부족(${userStores.length})`);
|
|
11091
|
+
for (const rel of userStores) {
|
|
11092
|
+
const d = seeded();
|
|
11093
|
+
const fp = path.join(d, '.harness', rel.replace(/\//g, path.sep));
|
|
11094
|
+
if (!fs.existsSync(fp)) continue;
|
|
11095
|
+
fs.writeFileSync(fp, '{ "broken": ');
|
|
11096
|
+
const broken = fs.readFileSync(fp, 'utf8');
|
|
11097
|
+
for (const a of MUT) {
|
|
11098
|
+
R(d, a);
|
|
11099
|
+
if (fs.existsSync(fp) && fs.readFileSync(fp, 'utf8') !== broken) { bad.push(`전수:${rel}←${a.join(' ')}`); break; }
|
|
11100
|
+
}
|
|
11101
|
+
}
|
|
11102
|
+
}
|
|
11103
|
+
|
|
11104
|
+
// ── "손상 ≠ 없음": 읽기 계열 명령이 손상 스토어를 **빈 값**으로 오인해 엉뚱한 진단을 내지 않는가.
|
|
11105
|
+
// 검수가 지적한 형태다 — `creds refresh` 가 "등록된 서비스 없음", `next-action take` 가 "큐 비어있음" 이라 했다.
|
|
11106
|
+
// 사용자는 등록이 지워졌다고 믿고 다시 등록하거나 handoff 를 또 돌린다. 원인(손상)은 끝까지 안 보인다.
|
|
11107
|
+
// ⚠ 대조군이 반드시 필요하다 — **정상 스토어에서 진짜로 없는** 경우까지 store_corrupt 라 하면 오차단이다.
|
|
11108
|
+
{
|
|
11109
|
+
const codeOf = (r) => { try { return (JSON.parse(String(r.stdout || '').trim()) || {}).code; } catch { return null; } };
|
|
11110
|
+
const seedCreds = (d) => R(d, ['creds', 'register', 'svc', '--env-var', 'K']);
|
|
11111
|
+
const corrupt = (d, f) => fs.writeFileSync(path.join(d, '.harness', f), '{ "broken": ');
|
|
11112
|
+
// (a) 손상 → store_corrupt 로 진단
|
|
11113
|
+
for (const [label, seed, file, act] of [
|
|
11114
|
+
['creds check', seedCreds, 'credentials.local.json', ['creds', 'check']],
|
|
11115
|
+
['creds refresh', seedCreds, 'credentials.local.json', ['creds', 'refresh', 'svc']],
|
|
11116
|
+
['next-action take', (d) => R(d, ['next-action', 'add', 'aaa']), 'next-action-queue.json', ['next-action', 'take', '0']],
|
|
11117
|
+
]) {
|
|
11118
|
+
const d = mk(); if (seed(d).status !== 0) { bad.push(`손상진단:${label}:셋업실패`); continue; }
|
|
11119
|
+
corrupt(d, file);
|
|
11120
|
+
const r = R(d, act.concat(['--json']));
|
|
11121
|
+
if (r.status === 0) bad.push(`손상진단:${label}:exit0`);
|
|
11122
|
+
if (codeOf(r) !== 'store_corrupt') bad.push(`손상진단:${label}:코드(${codeOf(r)})`);
|
|
11123
|
+
}
|
|
11124
|
+
// (b) 손상 → 사람용 읽기(list)도 조용히 "없음" 이라 하지 않는다
|
|
11125
|
+
{ const d = mk(); seedCreds(d); corrupt(d, 'credentials.local.json');
|
|
11126
|
+
const r = R(d, ['creds', 'list']);
|
|
11127
|
+
if (r.status === 0) bad.push('손상진단:creds list:조용히통과'); }
|
|
11128
|
+
// (c) 대조군 — 정상 스토어에서 **진짜 없는** 경우는 store_corrupt 가 아니어야 한다(오차단 방지)
|
|
11129
|
+
{ const d = mk(); seedCreds(d);
|
|
11130
|
+
const r = R(d, ['creds', 'refresh', 'nosuch', '--json']);
|
|
11131
|
+
if (codeOf(r) === 'store_corrupt') bad.push('손상진단:대조군_미등록을_손상이라함'); }
|
|
11132
|
+
{ const d = mk(); // 빈 registry(정상)는 그냥 "없음" 이어야 한다
|
|
11133
|
+
const r = R(d, ['creds', 'list']);
|
|
11134
|
+
if (r.status !== 0) bad.push('손상진단:대조군_빈registry_오차단'); }
|
|
11135
|
+
{ const d = mk(); R(d, ['next-action', 'add', 'aaa']); R(d, ['next-action', 'take', '0']);
|
|
11136
|
+
const r = R(d, ['next-action', 'take', '0', '--json']); // 정상인데 비어 있음 → 손상 아님
|
|
11137
|
+
if (codeOf(r) === 'store_corrupt') bad.push('손상진단:대조군_빈큐를_손상이라함'); }
|
|
11138
|
+
}
|
|
11139
|
+
|
|
11140
|
+
dbg.bad = bad.slice(0, 12);
|
|
11141
|
+
ok = bad.length === 0;
|
|
11142
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
11143
|
+
console.log(ok ? `✓ R(1.36.114) 방치 명령의 기지 클래스: 손상 스토어 6종(안 씀·성공이라 안 함·스택 없음·정상 오차단 없음·--json 계약) · 사용자 스토어 ${dbg.stores}종 전수 무유실 · 개행 위조 0(creds·constraints)`
|
|
11144
|
+
: '✗ 1.36.114 방치 명령 클래스 실패 ' + JSON.stringify(dbg));
|
|
11145
|
+
if (!ok) failed++;
|
|
11146
|
+
}
|
|
11147
|
+
|
|
10841
11148
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
10842
11149
|
if (failed > 0) process.exit(1);
|
|
10843
11150
|
|