leerness 1.36.111 → 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 +246 -61
- package/lib/clarify.js +4 -1
- package/lib/pure-utils.js +43 -2
- package/package.json +1 -1
- package/scripts/e2e.js +362 -1
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.111는 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') 시 호스트 프로세스 오염.
|
|
@@ -660,10 +660,13 @@ function _readManifestChecked(root) {
|
|
|
660
660
|
const mf = path.join(absRoot(root || process.cwd()), '.harness', 'manifest.json');
|
|
661
661
|
if (!exists(mf)) return { path: mf, exists: false, corrupt: false, data: {} };
|
|
662
662
|
let raw = null;
|
|
663
|
-
|
|
663
|
+
// 1.36.112: `reason` 은 한국어 문장뿐이라, 이걸 끼워 넣는 영어 문장이 한국어를 새게 한다
|
|
664
|
+
// (영어 손상 경고 안에 `읽기 실패` 가 그대로 박혔다 — 이 저장소가 래칫으로 세는 바로 그 결함이다).
|
|
665
|
+
// 기존 소비자(`corruptReason`)를 위해 `reason` 은 그대로 두고, 표시 쪽이 언어를 고르도록 **안정 코드**를 함께 싣는다.
|
|
666
|
+
try { raw = read(mf); } catch { return { path: mf, exists: true, corrupt: true, reason: '읽기 실패', code: 'read_failed', data: {} }; }
|
|
664
667
|
let j = null;
|
|
665
|
-
try { j = JSON.parse(raw); } catch { return { path: mf, exists: true, corrupt: true, reason: 'JSON 파싱 실패', data: {} }; }
|
|
666
|
-
if (!j || typeof j !== 'object' || Array.isArray(j)) return { path: mf, exists: true, corrupt: true, reason: '최상위가 객체가 아님', data: {} };
|
|
668
|
+
try { j = JSON.parse(raw); } catch { return { path: mf, exists: true, corrupt: true, reason: 'JSON 파싱 실패', code: 'json_parse_failed', data: {} }; }
|
|
669
|
+
if (!j || typeof j !== 'object' || Array.isArray(j)) return { path: mf, exists: true, corrupt: true, reason: '최상위가 객체가 아님', code: 'not_an_object', data: {} };
|
|
667
670
|
return { path: mf, exists: true, corrupt: false, data: j };
|
|
668
671
|
}
|
|
669
672
|
function _projectMode(root) {
|
|
@@ -6430,7 +6433,7 @@ function _selfTestCases() {
|
|
|
6430
6433
|
return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
|
|
6431
6434
|
&& _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
|
|
6432
6435
|
&& _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk() && _p0104UsageOk()
|
|
6433
|
-
&& _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk() && _p0097AsyncLockOk() && _p0109ClaimGateOk() && _p0110HonestyOk();
|
|
6436
|
+
&& _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk() && _p0097AsyncLockOk() && _p0109ClaimGateOk() && _p0110HonestyOk() && _p0112CarryOk();
|
|
6434
6437
|
} },
|
|
6435
6438
|
{ name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
|
|
6436
6439
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
|
|
@@ -8164,6 +8167,51 @@ function _p0110HonestyOk() {
|
|
|
8164
8167
|
return symmetric && neverBlocks && highSymmetric && futureScopeAllowed && noFalseBlock && optIn;
|
|
8165
8168
|
} catch { return false; }
|
|
8166
8169
|
}
|
|
8170
|
+
// 1.36.112: 이월 블록의 **경계 판정**. 쓰는 쪽(_managedMerge)과 읽는 쪽(_splitPreserved)이 같은 상수를 봐야 한다 —
|
|
8171
|
+
// 어긋나면 `context budget` 이 이월분을 0 으로 보고하고, 두 번(1.36.95 · 1.36.105) 미뤄진 부채가 다시 안 보이게 된다.
|
|
8172
|
+
// 양방향으로 단언한다: 있는 경계를 찾는지, 그리고 **없는 경계를 만들지 않는지**. 후자가 더 위험하다 —
|
|
8173
|
+
// 사용자 산문을 이월분으로 오인하면 "지침의 100% 가 이월" 이라는 거짓 보고가 나온다.
|
|
8174
|
+
function _p0112CarryOk() {
|
|
8175
|
+
try {
|
|
8176
|
+
const p = require('../lib/pure-utils');
|
|
8177
|
+
const base = '# T\n\n- managed line\n';
|
|
8178
|
+
const merged = p._managedMerge('AGENTS.md', base, base + '\n- CUSTOM-CARRY-A\n- CUSTOM-CARRY-B\n', '.harness/archive', new Set(), {});
|
|
8179
|
+
const s = p._splitPreserved(merged);
|
|
8180
|
+
// ① 무손실 — 관리분 + 이월분이 원문과 **바이트 동일**. 한쪽이라도 흘리면 계량이 조용히 틀린다.
|
|
8181
|
+
const lossless = (s.managed + s.preserved) === merged;
|
|
8182
|
+
// ② 경계가 옳은 쪽에 있다
|
|
8183
|
+
const placed = s.preserved.includes('CUSTOM-CARRY-A') && !s.managed.includes('CUSTOM-CARRY-A')
|
|
8184
|
+
&& s.managed.includes('- managed line') && s.preserved.includes(p.PRESERVED_TAG);
|
|
8185
|
+
// ③ 경계가 없으면 만들지 않는다
|
|
8186
|
+
const noop = (() => { const n = p._splitPreserved(base); return n.preserved === '' && n.managed === base && n.at === -1; })();
|
|
8187
|
+
// ④ 태그가 지워진 구형 설치본은 제목으로 가르되, **생성 안내문이 뒤따를 때만** 인정한다.
|
|
8188
|
+
const legacy = (() => {
|
|
8189
|
+
const r = p._splitPreserved(base + '\n---\n## Preserved previous content\n\n> 이전 버전에서 이어진 내용. 전체 원본 백업: `.harness/archive`\n\n- OLD-LINE\n');
|
|
8190
|
+
return r.preserved.includes('OLD-LINE') && !r.managed.includes('OLD-LINE');
|
|
8191
|
+
})();
|
|
8192
|
+
// ④-b 같은 제목이어도 안내문이 없으면 경계가 아니다 — 사용자가 쓴 동명 섹션을 통째로 삼키지 않기 위해서다.
|
|
8193
|
+
// 편향은 **과소 보고** 쪽에 둔다: 못 세는 것보다, 사용자 산문을 "옮기세요" 라고 권하는 쪽이 나쁘다.
|
|
8194
|
+
const legacyNeedsNote = p._splitPreserved(base + '\n---\n## Preserved previous content\n\n- 사용자가 직접 쓴 항목\n').preserved === '';
|
|
8195
|
+
// ⑤ 본문에 그 낱말이 **문장으로** 나오는 것만으로는 가르지 않는다(제목 줄 전체를 앵커로 쓰는 이유)
|
|
8196
|
+
const inline = (() => {
|
|
8197
|
+
const r = p._splitPreserved('# T\n\nWe keep Preserved previous content in the wiki, not here.\n');
|
|
8198
|
+
return r.preserved === '' && r.at === -1;
|
|
8199
|
+
})();
|
|
8200
|
+
// ⑤-b 태그가 **문장 안에 인용**된 경우도 경계가 아니다 — 생성기는 태그를 자기 줄에 쓰고 바로 다음 줄에 제목을 쓴다.
|
|
8201
|
+
// (leerness 자신을 설명하는 문서가 이 마커를 인용하면, 그 뒤 전부가 '이월분' 이 되던 형태다.)
|
|
8202
|
+
const quoted = p._splitPreserved(`# T\n\n우리는 이 마커를 씁니다: \`${p.PRESERVED_TAG}\`\n\n- 실제 지침\n`).preserved === '';
|
|
8203
|
+
// ⑥ 빈/비문자열 입력에서 죽지 않는다
|
|
8204
|
+
const edge = p._splitPreserved('').preserved === '' && p._splitPreserved(null).managed === ''
|
|
8205
|
+
&& p._splitPreserved(undefined).at === -1;
|
|
8206
|
+
// ⑦ 병적 입력에서 선형이어야 한다. 초안은 접두 **전체**를 정규식으로 훑어 개행 40만 개 입력이 **52,790ms** 였다
|
|
8207
|
+
// (5만 642ms → 40만 52,790ms, 명백한 2차식). 꼬리만 보도록 고친 뒤 0.2ms. 넉넉히 1.5초로 건다.
|
|
8208
|
+
const perf = (() => {
|
|
8209
|
+
const big = '\n'.repeat(400000) + p.PRESERVED_TAG + '\n## Preserved previous content\n';
|
|
8210
|
+
const t0 = Date.now(); p._splitPreserved(big); return (Date.now() - t0) < 1500;
|
|
8211
|
+
})();
|
|
8212
|
+
return lossless && placed && noop && legacy && legacyNeedsNote && inline && quoted && edge && perf;
|
|
8213
|
+
} catch { return false; }
|
|
8214
|
+
}
|
|
8167
8215
|
function _p0109ClaimGateOk() {
|
|
8168
8216
|
try {
|
|
8169
8217
|
const an = require('../lib/analyzers');
|
|
@@ -9784,8 +9832,8 @@ function resumeCmd(root) {
|
|
|
9784
9832
|
log('');
|
|
9785
9833
|
log(grn(`## 사전 정리된 next-actions (${plan.nextActions.length}건)`));
|
|
9786
9834
|
for (const a of plan.nextActions) {
|
|
9787
|
-
log(` ${a.icon || '•'} ${a.title}`);
|
|
9788
|
-
if (a.command) log(dim(` \`${a.command}\``));
|
|
9835
|
+
log(` ${a.icon || '•'} ${_lineSafe(a.title)}`);
|
|
9836
|
+
if (a.command) log(dim(` \`${_lineSafe(a.command)}\``));
|
|
9789
9837
|
}
|
|
9790
9838
|
log('');
|
|
9791
9839
|
log(dim(` → 즉시 task 추가: leerness next-action take`));
|
|
@@ -9850,7 +9898,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9850
9898
|
}
|
|
9851
9899
|
log(yel(` 📥 delivered 패턴 후보 ${detected.candidates.length}건:`));
|
|
9852
9900
|
detected.candidates.forEach(c => {
|
|
9853
|
-
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)}…`);
|
|
9854
9902
|
});
|
|
9855
9903
|
log('');
|
|
9856
9904
|
if (apply) {
|
|
@@ -9891,7 +9939,11 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9891
9939
|
for (const r of list) {
|
|
9892
9940
|
const statusIcon = r.status === 'completed' ? '✓' : (r.status === 'dropped' ? '✗' : (r.status === 'in-progress' ? '▶' : '◯'));
|
|
9893
9941
|
const recordedDay = (r.recordedAt || '').slice(0, 10);
|
|
9894
|
-
|
|
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 ? '…' : ''}`);
|
|
9895
9947
|
if (r.linkedTaskIds && r.linkedTaskIds.length > 0) log(dim(` linked tasks: ${r.linkedTaskIds.join(', ')}`));
|
|
9896
9948
|
}
|
|
9897
9949
|
return;
|
|
@@ -9929,7 +9981,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9929
9981
|
} else {
|
|
9930
9982
|
log(red(` ⚠ 누락 후보 ${audit.missing.length}건 (open 상태이나 task/plan/decisions 매칭 없음):`));
|
|
9931
9983
|
for (const m of audit.missing) {
|
|
9932
|
-
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 ? '…' : ''}`);
|
|
9933
9985
|
log(dim(` ${m.recordedAt.slice(0, 10)} · hits=${m.hits}/${m.words}`));
|
|
9934
9986
|
}
|
|
9935
9987
|
log('');
|
|
@@ -9939,7 +9991,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9939
9991
|
log('');
|
|
9940
9992
|
log(grn(` ✓ tracked ${audit.tracked.length}건 (open + task/plan/decisions 매칭됨):`));
|
|
9941
9993
|
for (const t of audit.tracked.slice(0, 5)) {
|
|
9942
|
-
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})`)}`);
|
|
9943
9995
|
}
|
|
9944
9996
|
}
|
|
9945
9997
|
if (audit.stale.length > 0) {
|
|
@@ -9947,7 +9999,7 @@ function requestsCmd(root, sub, ...rest) {
|
|
|
9947
9999
|
log(yel(` ⏳ stale ${audit.stale.length}건 (7일+ open):`));
|
|
9948
10000
|
for (const s of audit.stale.slice(0, 5)) {
|
|
9949
10001
|
const days = Math.floor((Date.now() - new Date(s.recordedAt).getTime()) / 86400000);
|
|
9950
|
-
log(` • [${s.id}] ${days}일 ${s.text.slice(0, 70)}…`);
|
|
10002
|
+
log(` • [${s.id}] ${days}일 ${_lineSafe(s.text).slice(0, 70)}…`);
|
|
9951
10003
|
}
|
|
9952
10004
|
}
|
|
9953
10005
|
return;
|
|
@@ -11080,8 +11132,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11080
11132
|
log('');
|
|
11081
11133
|
for (let i = 0; i < state.queue.length; i++) {
|
|
11082
11134
|
const a = state.queue[i];
|
|
11083
|
-
log(` [${i}] ${a.icon || '•'} ${a.title}`);
|
|
11084
|
-
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)}\``);
|
|
11085
11137
|
}
|
|
11086
11138
|
log('');
|
|
11087
11139
|
log(` → 가져오기: leerness next-action take [N] (N 생략 시 최신 [${state.queue.length - 1}])`);
|
|
@@ -11093,8 +11145,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11093
11145
|
if (isNaN(n) || n < 0 || n >= state.queue.length) { fail(`잘못된 index: ${n} (0~${state.queue.length - 1})`); return process.exit(1); }
|
|
11094
11146
|
const action = state.queue[n];
|
|
11095
11147
|
log(`# leerness next-action take [${n}] (1.9.201)`);
|
|
11096
|
-
log(` ${action.icon || '•'} ${action.title}`);
|
|
11097
|
-
if (action.command) log(` \`${action.command}\``);
|
|
11148
|
+
log(` ${action.icon || '•'} ${_lineSafe(action.title)}`);
|
|
11149
|
+
if (action.command) log(` \`${_lineSafe(action.command)}\``);
|
|
11098
11150
|
// task add 자동 호출
|
|
11099
11151
|
try {
|
|
11100
11152
|
const taskTitle = action.title.replace(/^[^\w가-힣]+/, '').slice(0, 100);
|
|
@@ -11111,8 +11163,8 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
11111
11163
|
const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
|
|
11112
11164
|
if (taskResult.status === 0) {
|
|
11113
11165
|
const m = (taskResult.stdout || '').match(/T-\d{4,}/);
|
|
11114
|
-
log(` ✓ task 추가: ${m ? m[0] : '?'} — "${taskTitle}"`);
|
|
11115
|
-
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)}`);
|
|
11116
11168
|
} else {
|
|
11117
11169
|
log(` ⚠ task add 실패 (exit ${taskResult.status}) — 수동: leerness task add "${taskTitle}"`);
|
|
11118
11170
|
}
|
|
@@ -11630,7 +11682,11 @@ function planAdd(root, text) {
|
|
|
11630
11682
|
// 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
|
|
11631
11683
|
const { id, tid } = _withLock(progressPath(root), () => {
|
|
11632
11684
|
const id = nextId(root, 'M');
|
|
11633
|
-
|
|
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`);
|
|
11634
11690
|
const tid = nextId(root, 'T');
|
|
11635
11691
|
upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction });
|
|
11636
11692
|
return { id, tid };
|
|
@@ -12234,8 +12290,8 @@ function lessonListCmd(root, opts = {}) {
|
|
|
12234
12290
|
}
|
|
12235
12291
|
log(`총 ${lessons.length}건${tagFilter ? ` (tag: ${tagFilter})` : ''}${queryFilter ? ` (query: "${queryFilter}")` : ''}:`);
|
|
12236
12292
|
for (const l of lessons) {
|
|
12237
|
-
log(`\n[${l.date || '?'}]${l.tag ? ` #${l.tag}` : ''}`);
|
|
12238
|
-
log(` ${l.text}`);
|
|
12293
|
+
log(`\n[${_lineSafe(l.date || '?')}]${l.tag ? ` #${_lineSafe(l.tag)}` : ''}`);
|
|
12294
|
+
log(` ${_lineSafe(l.text)}`); // 1.36.113: 저장(md)만 막혀 있고 목록 출력은 raw 였다
|
|
12239
12295
|
}
|
|
12240
12296
|
}
|
|
12241
12297
|
|
|
@@ -12323,11 +12379,13 @@ function decisionListCmd(root, opts = {}) {
|
|
|
12323
12379
|
log(`# 🧠 Decisions (1.9.118)${queryFilter ? ` — query: "${queryFilter}"` : ''}\n`);
|
|
12324
12380
|
if (!decisions.length) return ok(queryFilter ? `"${queryFilter}" 매칭 decision 없음` : 'decisions 비어있음');
|
|
12325
12381
|
log(`총 ${decisions.length}건${queryFilter ? ` (query: "${queryFilter}")` : ''}:`);
|
|
12382
|
+
// 1.36.113: 저장(md)은 _lineSafe 로 막혀 있는데 **이 목록 출력만** raw 였다 — 보호가 파일에만 걸려 있었다.
|
|
12383
|
+
// 개행이 있으면 `[2099-01-01] 가짜 결정` 같은 줄이 별도 항목처럼 보인다(사람도 AI 도 구별 못 한다).
|
|
12326
12384
|
for (const d of decisions) {
|
|
12327
|
-
log(`\n[${d.date || '?'}] ${d.title}`);
|
|
12328
|
-
if (d.reason) log(` Reason: ${d.reason}`);
|
|
12329
|
-
if (d.alternatives) log(` Alternatives: ${d.alternatives}`);
|
|
12330
|
-
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)}`);
|
|
12331
12389
|
}
|
|
12332
12390
|
}
|
|
12333
12391
|
|
|
@@ -12431,10 +12489,10 @@ function taskRelink(root) {
|
|
|
12431
12489
|
.map(r => ({ r, score: _jaccard(milestoneTokens, _tokenizeForSim(r.request)) }))
|
|
12432
12490
|
.filter(x => x.score >= minScore)
|
|
12433
12491
|
.sort((a, b) => b.score - a.score);
|
|
12434
|
-
log(`\n${m.id}: ${m.text}`);
|
|
12492
|
+
log(`\n${m.id}: ${_lineSafe(m.text)}`);
|
|
12435
12493
|
if (!candidates.length) {
|
|
12436
12494
|
log(` ⓘ 매칭 후보 없음 (score ≥ ${minScore})`);
|
|
12437
|
-
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}"`);
|
|
12438
12496
|
continue;
|
|
12439
12497
|
}
|
|
12440
12498
|
const best = candidates[0];
|
|
@@ -14020,7 +14078,7 @@ function handoff(root) {
|
|
|
14020
14078
|
log('');
|
|
14021
14079
|
log(yl4(`## 📥 사용자 요청 자동 완료 가능 (1.9.224, ${delivered.candidates.length}건)`));
|
|
14022
14080
|
delivered.candidates.slice(0, 5).forEach(c => {
|
|
14023
|
-
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 ? '…' : ''}`);
|
|
14024
14082
|
});
|
|
14025
14083
|
if (delivered.candidates.length > 5) {
|
|
14026
14084
|
log(dm4(` ... +${delivered.candidates.length - 5}건 더`));
|
|
@@ -14240,8 +14298,8 @@ function handoff(root) {
|
|
|
14240
14298
|
if (_showAdvice) {
|
|
14241
14299
|
log(grn(`## 🎯 다음 단계 자동 제안 (1.9.194 E축 — 게으름 방지) — 키워드 "${keyword}"`));
|
|
14242
14300
|
for (const a of actions) {
|
|
14243
|
-
log(dim(` ${a.icon} ${a.title}`));
|
|
14244
|
-
if (a.command) log(dim(` \`${a.command}\``));
|
|
14301
|
+
log(dim(` ${a.icon} ${_lineSafe(a.title)}`));
|
|
14302
|
+
if (a.command) log(dim(` \`${_lineSafe(a.command)}\``));
|
|
14245
14303
|
}
|
|
14246
14304
|
}
|
|
14247
14305
|
// 1.9.201: queue 자동 저장 — `leerness next-action take` 로 즉시 task add 가능 (토글과 무관: 상태다)
|
|
@@ -18260,7 +18318,7 @@ function retroCmd(root) {
|
|
|
18260
18318
|
|
|
18261
18319
|
log(`\n## 🧠 최근 결정 (top 5)`);
|
|
18262
18320
|
if (!agg.recentDecisions.length) log(' (없음)');
|
|
18263
|
-
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
|
|
18321
|
+
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${_lineSafe(d.title)}`));
|
|
18264
18322
|
|
|
18265
18323
|
if (agg.durations.length >= 4) {
|
|
18266
18324
|
const mid = Math.floor(agg.durations.length / 2);
|
|
@@ -18625,7 +18683,7 @@ function _brainstormWorkspace(rootBase, topic) {
|
|
|
18625
18683
|
log(`\n## ${path.basename(p)} (${n}건)`);
|
|
18626
18684
|
if (h.decisions.length) {
|
|
18627
18685
|
log(` 🧠 결정 (${h.decisions.length})`);
|
|
18628
|
-
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)}`));
|
|
18629
18687
|
}
|
|
18630
18688
|
if (h.skills.length) {
|
|
18631
18689
|
log(` 📚 스킬 (${h.skills.length})`);
|
|
@@ -18721,7 +18779,7 @@ function brainstormCmd(root, topic) {
|
|
|
18721
18779
|
// 1.9.15: 모든 출력에 출처 파일:라인 표시
|
|
18722
18780
|
if (hits.decisions.length) {
|
|
18723
18781
|
log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
|
|
18724
|
-
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)}`));
|
|
18725
18783
|
}
|
|
18726
18784
|
if (hits.skills.length) {
|
|
18727
18785
|
log(`\n## 📚 관련 스킬 (${hits.skills.length}) — 시작 전 \`skill info <id>\` 권장`);
|
|
@@ -20391,7 +20449,7 @@ function reuseFind(root, query) {
|
|
|
20391
20449
|
log(`# reuse find: "${query}"`);
|
|
20392
20450
|
if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
|
|
20393
20451
|
log(`${matches.length}개 후보:`);
|
|
20394
|
-
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)}`);
|
|
20395
20453
|
log(`\n💡 새로 만들기 전에 위 자원을 재사용/확장 가능한지 확인하세요.`);
|
|
20396
20454
|
}
|
|
20397
20455
|
|
|
@@ -23390,7 +23448,8 @@ function briefCmd(root, sub) {
|
|
|
23390
23448
|
const brief = _loadBrief(root);
|
|
23391
23449
|
if (has('--json')) { log(JSON.stringify(brief, null, 2)); return; }
|
|
23392
23450
|
log(cy(`# leerness brief — ${brief.project}`));
|
|
23393
|
-
|
|
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('(미입력)')}`); }
|
|
23394
23453
|
log('');
|
|
23395
23454
|
log(dm(` 채움 ${_briefFilled(brief)}/${_BRIEF_FIELDS.length} · 설정: leerness brief set --intro "..." · 복사용: leerness brief export`));
|
|
23396
23455
|
return;
|
|
@@ -23455,8 +23514,8 @@ function contextCmd(root, opts = {}) {
|
|
|
23455
23514
|
const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
23456
23515
|
const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
23457
23516
|
log(cy(`# leerness context (1.9.292) — 에이전트 온보딩 컨텍스트 (v${VERSION})`));
|
|
23458
|
-
if (intent) log(` 🎯 의도: ${intent}`);
|
|
23459
|
-
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)`));
|
|
23460
23519
|
log('');
|
|
23461
23520
|
if (ctx.currentTask) {
|
|
23462
23521
|
log(gr(`▶ 현재 작업: ${ctx.currentTask.id} — ${ctx.currentTask.request}`));
|
|
@@ -23464,12 +23523,12 @@ function contextCmd(root, opts = {}) {
|
|
|
23464
23523
|
} else log(dm('▶ 현재 진행 중 작업 없음'));
|
|
23465
23524
|
log('');
|
|
23466
23525
|
log(`📥 미답 요청: ${ctx.openRequests.count}건`);
|
|
23467
|
-
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: 개행이 가짜 항목 줄을 만든다
|
|
23468
23527
|
log('');
|
|
23469
23528
|
log(`🧠 메모리: 진행 ${memory.tasksInProgress} / 결정 ${memory.decisions} / 룰 ${memory.rulesActive} / 교훈 ${memory.lessons}`);
|
|
23470
|
-
if (recentDecisions.length) { log(''); log('🗂 최근 결정:'); recentDecisions.forEach(d => log(dm(` • ${d.date || '?'} — ${d.title}`))); }
|
|
23471
|
-
if (ctx.activeRules.length) { log(''); log('⚡ 활성 룰:'); ctx.activeRules.forEach(r => log(dm(` • [${r.id}] (${r.trigger}) ${r.rule}`))); }
|
|
23472
|
-
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) : ''}`))); }
|
|
23473
23532
|
return ctx;
|
|
23474
23533
|
}
|
|
23475
23534
|
function stateCmd(root, sub, ...args) {
|
|
@@ -24890,7 +24949,7 @@ function incidentListCmd(root) {
|
|
|
24890
24949
|
try {
|
|
24891
24950
|
const j = JSON.parse(read(path.join(dir, f)));
|
|
24892
24951
|
const e = j.payload?.error || j.payload?.message || '(no description)';
|
|
24893
|
-
log(` ${j.id} · ${String(e).slice(0, 80)}`);
|
|
24952
|
+
log(` ${j.id} · ${_lineSafe(String(e)).slice(0, 80)}`);
|
|
24894
24953
|
} catch {}
|
|
24895
24954
|
}
|
|
24896
24955
|
}
|
|
@@ -24918,7 +24977,7 @@ async function incidentHandleCmd(root, id) {
|
|
|
24918
24977
|
log(`incident: ${j.id} · permission mode: ${p.mode || 'basic'}`);
|
|
24919
24978
|
const err = j.payload?.error || j.payload?.message || '';
|
|
24920
24979
|
const stack = j.payload?.stack || '';
|
|
24921
|
-
log(`error: ${String(err).slice(0, 200)}`);
|
|
24980
|
+
log(`error: ${_lineSafe(String(err)).slice(0, 200)}`);
|
|
24922
24981
|
if (stack) log(`stack head:\n${String(stack).split('\n').slice(0, 4).join('\n')}`);
|
|
24923
24982
|
// (1) feature impact 자동 회수 — error 키워드 매칭
|
|
24924
24983
|
try {
|
|
@@ -24957,7 +25016,9 @@ async function incidentHandleCmd(root, id) {
|
|
|
24957
25016
|
j.permissionMode = p.mode || 'basic';
|
|
24958
25017
|
writeUtf8(fp, JSON.stringify(j, null, 2) + '\n');
|
|
24959
25018
|
ok(`incident handled: ${j.id} (분석/회수 완료)`);
|
|
24960
|
-
|
|
25019
|
+
// 1.36.113: 이 줄은 **복붙해서 실행하라고** 주는 명령이다 — 외부 webhook 이 넣은 개행이 여기서 갈리면
|
|
25020
|
+
// 따옴표가 닫히지 않은 채 둘째 줄이 별도 명령처럼 보인다. 인용 대상은 반드시 한 줄로 접는다.
|
|
25021
|
+
log(` → 후속: leerness agent "fix: ${_lineSafe(String(err)).slice(0, 80)}" / leerness verify-code . / leerness deploy auto`);
|
|
24961
25022
|
}
|
|
24962
25023
|
|
|
24963
25024
|
// ---- (3) Webhook Listener ----
|
|
@@ -25799,7 +25860,7 @@ function lspCmd(root, sub, ...args) {
|
|
|
25799
25860
|
} else {
|
|
25800
25861
|
log(`# leerness lsp references (1.9.167)`);
|
|
25801
25862
|
log(`symbol: "${name}" · ${refs.length} references · ${dt}ms`);
|
|
25802
|
-
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)}`));
|
|
25803
25864
|
if (refs.length > 30) log(` ... ${refs.length - 30} more`);
|
|
25804
25865
|
}
|
|
25805
25866
|
try { _recordRun(root, { kind: 'lsp_references', name, count: refs.length, durationMs: dt, ok: true }); } catch {}
|
|
@@ -26187,25 +26248,88 @@ async function main() {
|
|
|
26187
26248
|
const _cy = s => _tty2 ? `\x1b[36m${s}\x1b[0m` : s, _dm = s => _tty2 ? `\x1b[2m${s}\x1b[0m` : s;
|
|
26188
26249
|
const _bRoot = absRoot(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd());
|
|
26189
26250
|
const _bj = has('--json');
|
|
26190
|
-
|
|
26251
|
+
// 1.36.112 (T-0100 일부 상환): 이 명령은 i18n 래칫의 측정 대상이다. 여기에 한국어 줄을 더하면 부채가 는다 —
|
|
26252
|
+
// 손대는 표면은 영어화까지 하고 간다(leerness 는 한국어 우선 도구가 아니다). 아래 `_bt` 로 고른다.
|
|
26253
|
+
const _bEn = _uiLang(_bRoot) === 'en';
|
|
26254
|
+
const _bt = (ko, en) => (_bEn ? en : ko);
|
|
26255
|
+
if (!exists(path.join(_bRoot, '.harness'))) { failJson(_bj, 'harness_missing', _bt(`leerness 미설치: ${_bRoot} — 먼저 leerness init`, `leerness not installed: ${_bRoot} — run leerness init first`)); return; }
|
|
26191
26256
|
const _tok = (s) => Math.round(String(s || '').length / 3.2); // 한글 혼합 대략치 — 절대값이 아니라 추세/비교용
|
|
26192
26257
|
const parts = [];
|
|
26258
|
+
// 1.36.112: 지침 파일을 **관리분 / 이월분**으로 갈라 잰다.
|
|
26259
|
+
// 종전엔 `AGENTS.md 4,248 tok` 처럼 덩어리로만 보여, 그 안의 몇 %가 마이그레이션이 실어 나른
|
|
26260
|
+
// 과거 내용인지 알 수 없었다. 실측(이 저장소)에서 지침 파일의 **78.0%(6,048 tok)** 가 이월분이고,
|
|
26261
|
+
// 그건 등급을 minimal 로 낮춰도 **그대로 남는다**(33,928 → 7,242 tok 로 줄지만 그중 83% 가 이월분).
|
|
26262
|
+
// 즉 예산 초과의 주원인이 첫 번째 권고로는 해결되지 않는데 출력이 그 사실을 숨기고 있었다.
|
|
26263
|
+
// 여기서는 재기만 한다 — 이월분 삭제는 1.36.95/1.36.105 가 안전을 증명 못 해 뺀 그대로 두고,
|
|
26264
|
+
// 그 판단(false-DROP 이 버그)은 유효하다. 다만 **비용을 모르면 우선순위를 못 정한다**.
|
|
26265
|
+
const { _splitPreserved } = require('../lib/pure-utils');
|
|
26266
|
+
let carriedTok = 0, carriedB = 0;
|
|
26267
|
+
const _carryFiles = [];
|
|
26193
26268
|
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
|
|
26194
26269
|
const p = path.join(_bRoot, f);
|
|
26195
|
-
parts.push({
|
|
26270
|
+
if (!exists(p)) { parts.push({ key: f, what: f, bytes: 0, tokens: 0, carriedTokens: 0 }); continue; }
|
|
26271
|
+
const txt = read(p);
|
|
26272
|
+
const sp = _splitPreserved(txt);
|
|
26273
|
+
const cT = sp.preserved ? _tok(sp.preserved) : 0;
|
|
26274
|
+
carriedTok += cT; carriedB += sp.preserved ? Buffer.byteLength(sp.preserved) : 0;
|
|
26275
|
+
if (cT) _carryFiles.push({ file: f, preserved: sp.preserved });
|
|
26276
|
+
parts.push({ key: f, what: f, bytes: fs.statSync(p).size, tokens: _tok(txt), carriedTokens: cT });
|
|
26277
|
+
}
|
|
26278
|
+
// 이월 안내는 "전체 원본 백업: `<경로>`" 라고 **약속**한다. "옮겨도 안전하다" 는 권고는 그 약속 위에 서 있으므로
|
|
26279
|
+
// 권고하기 전에 백업이 실재하는지 확인한다(설치본 표본 22건 중 4건은 .harness/archive 없이 복사된 사본이었다).
|
|
26280
|
+
// 판정은 **이월분을 가진 파일 전부**에 대해 한다. 종전엔 첫 파일(AGENTS.md)만 보고 그 결과를 두 파일에
|
|
26281
|
+
// 적용해, AGENTS 만 백업된 상태에서 "원본은 이미 보관돼 있습니다" 라고 말했다(검수 재현). 하나라도 없으면 false.
|
|
26282
|
+
let _archive = null;
|
|
26283
|
+
if (_carryFiles.length) {
|
|
26284
|
+
let allOk = true, shown = null;
|
|
26285
|
+
for (const c of _carryFiles) {
|
|
26286
|
+
try {
|
|
26287
|
+
const m = /(?:전체 원본 백업|Full original backup):\s*`([^`]+)`/.exec(c.preserved);
|
|
26288
|
+
if (!m) { allOk = false; continue; }
|
|
26289
|
+
const rel = m[1].trim();
|
|
26290
|
+
if (!shown) shown = rel;
|
|
26291
|
+
const abs = path.resolve(_bRoot, rel);
|
|
26292
|
+
// 안내 문구는 **프로젝트 파일에서 온 입력**이다. `../` 탈출이나 절대경로로 프로젝트 밖을 가리키면
|
|
26293
|
+
// 남의 디렉토리 구조를 백업으로 인정하게 된다(검수 재현: `../outside` → exists:true). 루트 안으로 가둔다.
|
|
26294
|
+
if (abs !== _bRoot && !abs.startsWith(_bRoot + path.sep)) { allOk = false; continue; }
|
|
26295
|
+
// 안내는 두 형식으로 존재한다 — 스냅샷 경로(`.harness/archive/leerness-1.36.100-…`)와
|
|
26296
|
+
// 아카이브 루트(`.harness/archive`, archiveRel 미주입 시의 폴백). 한쪽만 상정하면 백업이
|
|
26297
|
+
// 멀쩡한 프로젝트에 "백업이 없습니다" 라고 말한다(작성 중 실제로 냈다 — 오경보는 이 경고를 못 믿게 만든다).
|
|
26298
|
+
let found = exists(path.join(abs, 'files', c.file));
|
|
26299
|
+
if (!found && exists(abs)) {
|
|
26300
|
+
// 항목 수가 병적으로 많은 디렉토리에서 비용이 터지지 않게 상한을 둔다(정상 아카이브는 마이그레이션당 1개).
|
|
26301
|
+
try { found = fs.readdirSync(abs).slice(0, 500).some(d => exists(path.join(abs, d, 'files', c.file))); } catch {}
|
|
26302
|
+
}
|
|
26303
|
+
if (!found) allOk = false;
|
|
26304
|
+
} catch { allOk = false; }
|
|
26305
|
+
}
|
|
26306
|
+
if (shown) _archive = { path: shown, exists: allOk, files: _carryFiles.length };
|
|
26196
26307
|
}
|
|
26197
26308
|
// 1.36.105 (codex 검수 HIGH#7): AGENTS.md 가 "읽어라" 고 지목하는 .harness 문서를 빼고 재면 예산이 거짓이 된다
|
|
26198
26309
|
// (검수 재현: session-workflow.md 에 100,000자를 넣어도 ok:true). 지목된 문서를 합산에 넣는다.
|
|
26310
|
+
// 1.36.112 (독립 검수, 실제 설치본에서 조건 확인): 지목은 **파일 전체**에서 세야 맞다(이월 블록 안의
|
|
26311
|
+
// 언급도 AI 는 그대로 읽는다). 그러나 등급 절감으로 칠 수 있는 것은 **관리분 안의 지목뿐**이다 —
|
|
26312
|
+
// 이월 블록은 등급을 낮춰도 그대로 남으므로 거기서 나온 지목은 사라지지 않는다.
|
|
26313
|
+
// 구분하지 않으면 "등급을 낮추면 N tok 이 빠진다" 가 최악의 경우 전부 허수가 된다(_bench/v19-demo 가 그 형태다:
|
|
26314
|
+
// 옛 템플릿의 문서 목록 전체가 이월 블록 안에 들어가 있다). 두 집합을 따로 센다.
|
|
26315
|
+
let _refManagedTok = 0;
|
|
26199
26316
|
try {
|
|
26200
26317
|
const agp = path.join(_bRoot, 'AGENTS.md');
|
|
26201
|
-
const
|
|
26318
|
+
const _agTxt = exists(agp) ? read(agp) : '';
|
|
26319
|
+
const _REF_RE = /\.harness\/[A-Za-z0-9._-]+\.md/g;
|
|
26320
|
+
const refs = [...new Set(_agTxt.match(_REF_RE) || [])];
|
|
26321
|
+
const _managedRefs = new Set(_splitPreserved(_agTxt).managed.match(_REF_RE) || []);
|
|
26202
26322
|
let rb = 0, rt = 0, rn = 0;
|
|
26203
26323
|
for (const r of refs) {
|
|
26204
26324
|
const p = path.join(_bRoot, r.replace(/\//g, path.sep));
|
|
26205
26325
|
if (!exists(p)) continue;
|
|
26206
|
-
|
|
26326
|
+
const t = _tok(read(p));
|
|
26327
|
+
rn++; rb += fs.statSync(p).size; rt += t;
|
|
26328
|
+
if (_managedRefs.has(r)) _refManagedTok += t; // 등급을 낮추면 실제로 빠지는 몫만 따로 센다
|
|
26207
26329
|
}
|
|
26208
|
-
|
|
26330
|
+
// `what` 은 사람용 라벨이라 로케일에 따라 흔들린다 — 기계가 그걸 파싱하지 않도록 안정 키를 함께 싣는다
|
|
26331
|
+
// (1.36.111 에서 배운 것: 값을 로케일화할 거면 의존을 **명시**하고, 기계 계약은 흔들리지 않는 축을 준다).
|
|
26332
|
+
if (rn) parts.push({ key: 'referencedDocs', what: _bt(`AGENTS.md 가 지목하는 문서 ${rn}종`, `${rn} docs referenced by AGENTS.md`), bytes: rb, tokens: rt, count: rn });
|
|
26209
26333
|
} catch {}
|
|
26210
26334
|
// spawnChild 는 자기 스코프의 root 를 cwd 로 쓰므로 여기서는 직접 부른다(측정 대상이 빈 문자열이 되면 예산이 무의미해진다).
|
|
26211
26335
|
let ho = '';
|
|
@@ -26218,8 +26342,8 @@ async function main() {
|
|
|
26218
26342
|
});
|
|
26219
26343
|
ho = (r && r.stdout) || '';
|
|
26220
26344
|
} catch {}
|
|
26221
|
-
if (!ho) { log(_dm(' ⚠ handoff 출력을 측정하지 못했습니다 — 합계는 지침 파일만 반영합니다')); }
|
|
26222
|
-
parts.push({ what: 'handoff 출력', bytes: Buffer.byteLength(ho), tokens: _tok(ho) });
|
|
26345
|
+
if (!ho) { log(_dm(_bt(' ⚠ handoff 출력을 측정하지 못했습니다 — 합계는 지침 파일만 반영합니다', ' ⚠ could not measure handoff output — the total covers instruction files only'))); }
|
|
26346
|
+
parts.push({ key: 'handoff', what: _bt('handoff 출력', 'handoff output'), bytes: Buffer.byteLength(ho), tokens: _tok(ho) });
|
|
26223
26347
|
const total = parts.reduce((s, p) => s + p.tokens, 0);
|
|
26224
26348
|
const mode = _projectMode(_bRoot);
|
|
26225
26349
|
// 예산은 **실측에서** 정한다. 지목 문서(11종 4,133 tok)를 합산에 넣자 신규 standard 설치가 6,933 tok 이 됐다 —
|
|
@@ -26233,20 +26357,81 @@ async function main() {
|
|
|
26233
26357
|
// 등급을 파생시키는 표면은 손상 상태를 함께 실어야 한다 — 안 그러면 "예산 이내" 라는 판정이
|
|
26234
26358
|
// 실은 읽지 못한 등급의 기본값 위에서 나온 것이 된다. 술어를 공유하지 않으면 표면마다 진실이 갈린다.
|
|
26235
26359
|
const _bChk = _readManifestChecked(_bRoot);
|
|
26236
|
-
|
|
26360
|
+
// 등급을 낮추면 사라지는 것과 남는 것을 **각각** 센다. minimal 은 지목 문서를 아예 싣지 않지만(→0),
|
|
26361
|
+
// 이월분은 지침 파일 안에 있어 등급과 무관하게 남는다. 실측: 33,879 → 7,165 tok 인데 그 7,165 의 84% 가 이월분이다.
|
|
26362
|
+
const _refPart = parts.find(p => p.key === 'referencedDocs');
|
|
26363
|
+
// 등급 절감 = 지목 문서(minimal 은 아예 안 싣는다 → 0) + **관리분 템플릿 축소분**.
|
|
26364
|
+
// 종전엔 지목 문서만 셌고, 검수가 그게 실제 절감을 크게 밑돈다는 것을 재현했다(실제 6,600 vs 4,133).
|
|
26365
|
+
// 그 과소평가는 정렬을 뒤집을 수 있다 — 이월분이 두 값 **사이**에 있으면 순서가 틀린다.
|
|
26366
|
+
// 템플릿 축소분은 minimal 템플릿을 실제로 생성해 정확히 잰다. handoff 감소분은 여기서 못 재므로
|
|
26367
|
+
// 포함하지 않는다 → 이 값은 여전히 **하한**이고, 출력도 "최소" 라고 말한다.
|
|
26368
|
+
// ⚠ 축소분은 **템플릿끼리** 비교해야 한다. 처음엔 `관리분 − 최소템플릿` 으로 쟀는데, 관리 영역에 사용자가
|
|
26369
|
+
// 덧붙인 줄은 등급을 바꿔도 **삭제되지 않고 이월 블록으로 옮겨간다**(managedMerge 의 계약이 그렇다).
|
|
26370
|
+
// 그걸 절감으로 세면 "낮추면 N tok 빠진다" 가 다시 허수를 포함한다 — 현재 등급의 템플릿과 최소 템플릿만 견준다.
|
|
26371
|
+
let _tplShrink = 0;
|
|
26372
|
+
if (mode !== 'minimal') {
|
|
26373
|
+
try {
|
|
26374
|
+
const _l = _uiLang(_bRoot);
|
|
26375
|
+
const _now = coreFiles(_bRoot, _l, [], { mode });
|
|
26376
|
+
const _min = coreFiles(_bRoot, _l, [], { mode: 'minimal' });
|
|
26377
|
+
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
|
|
26378
|
+
if (!_now[f] || !_min[f]) continue;
|
|
26379
|
+
const d = _tok(_now[f]) - _tok(_min[f]);
|
|
26380
|
+
if (d > 0) _tplShrink += d;
|
|
26381
|
+
}
|
|
26382
|
+
} catch {}
|
|
26383
|
+
}
|
|
26384
|
+
// 지목 문서 중 **관리분에서 나온 것만** 절감으로 친다(이월 블록의 지목은 등급을 낮춰도 남는다).
|
|
26385
|
+
const _modeSaves = (mode !== 'minimal') ? (_refManagedTok + _tplShrink) : 0;
|
|
26386
|
+
if (_bj) {
|
|
26387
|
+
log(JSON.stringify({
|
|
26388
|
+
ok: !over && !_bChk.corrupt, root: _bRoot, lang: _bEn ? 'en' : 'ko', mode, budget, total, over,
|
|
26389
|
+
corrupt: !!_bChk.corrupt, corruptReason: _bChk.reason || null,
|
|
26390
|
+
// 1.36.112: 이월분은 등급으로 못 줄이는 **바닥값**이다 — 기계도 그걸 알아야 우선순위를 정한다.
|
|
26391
|
+
carried: { tokens: carriedTok, bytes: carriedB, pctOfTotal: total ? Math.round(carriedTok / total * 100) : 0, survivesModeChange: true, archive: _archive },
|
|
26392
|
+
// 하한임을 이름으로 못박는다 — handoff 감소분은 여기서 재지 않는다(정확한 값인 척하지 않는다).
|
|
26393
|
+
modeSavesAtLeast: _modeSaves,
|
|
26394
|
+
parts
|
|
26395
|
+
}, null, 2));
|
|
26396
|
+
if (over || _bChk.corrupt) process.exitCode = 1; return;
|
|
26397
|
+
}
|
|
26237
26398
|
log(_cy(`# leerness context budget — mode ${mode}`));
|
|
26238
|
-
|
|
26239
|
-
|
|
26240
|
-
log(` ${
|
|
26399
|
+
// 사유까지 언어를 맞춘다 — 영어 문장에 한국어 사유를 끼워 넣으면 그 줄은 여전히 누출이다.
|
|
26400
|
+
const _reasonEn = { read_failed: 'unreadable', json_parse_failed: 'invalid JSON', not_an_object: 'top level is not an object' }[_bChk.code] || 'unknown';
|
|
26401
|
+
if (_bChk.corrupt) log(_bt(` ⚠ manifest.json 손상(${_bChk.reason}) — 위 등급은 **저장값이 아니라 기본값**입니다: ${_bChk.path}`,
|
|
26402
|
+
` ⚠ manifest.json corrupt (${_reasonEn}) — the mode above is the **default, not the stored value**: ${_bChk.path}`));
|
|
26403
|
+
for (const p of parts) {
|
|
26404
|
+
const cp2 = p.carriedTokens ? _dm(_bt(` · 이월 ${p.carriedTokens} tok (${Math.round(p.carriedTokens / (p.tokens || 1) * 100)}%)`,
|
|
26405
|
+
` · carried-over ${p.carriedTokens} tok (${Math.round(p.carriedTokens / (p.tokens || 1) * 100)}%)`)) : '';
|
|
26406
|
+
log(` ${String(p.tokens).padStart(6)} tok ${String(p.bytes).padStart(7)} B ${p.what}${cp2}`);
|
|
26407
|
+
}
|
|
26408
|
+
log(` ${String(total).padStart(6)} tok ` + _bt(`합계 · 예산 ${budget} tok`, `total · budget ${budget} tok`));
|
|
26241
26409
|
log('');
|
|
26242
26410
|
if (over) {
|
|
26243
|
-
fail(`세션 적재가 예산을 넘습니다 (${total} > ${budget})`);
|
|
26244
|
-
|
|
26245
|
-
|
|
26411
|
+
fail(_bt(`세션 적재가 예산을 넘습니다 (${total} > ${budget})`, `session load exceeds the budget (${total} > ${budget})`));
|
|
26412
|
+
// 종전엔 권고 2줄이 **고정 순서**였고 절감액이 없었다. 등급을 낮춰도 예산을 못 지키는 프로젝트에서
|
|
26413
|
+
// 첫 권고를 따르는 것은 헛수고인데 출력이 그 사실을 말하지 않았다 — 실측 절감액 순으로 낸다.
|
|
26414
|
+
const rem = [];
|
|
26415
|
+
if (_modeSaves) rem.push({ save: _modeSaves, line: _bt(
|
|
26416
|
+
`등급을 낮추면 최소 ${_modeSaves} tok 이 빠집니다 (지목 문서 ${_refManagedTok} + 템플릿 축소 ${_tplShrink}, handoff 감소분 제외): leerness mode set minimal`,
|
|
26417
|
+
`lowering the mode drops at least ${_modeSaves} tok (referenced docs ${_refManagedTok} + template shrink ${_tplShrink}; handoff reduction not counted): leerness mode set minimal`) });
|
|
26418
|
+
if (carriedTok) {
|
|
26419
|
+
const where = _archive ? (_archive.exists
|
|
26420
|
+
? _bt(` — 원본은 ${_archive.path} 에 이미 보관돼 있습니다`, ` — the original is already archived at ${_archive.path}`)
|
|
26421
|
+
: _bt(` — ⚠ 안내가 가리키는 백업(${_archive.path})이 없습니다. 옮기기 전에 직접 복사해 두세요`, ` — ⚠ the referenced backup (${_archive.path}) is missing. Copy it yourself before moving anything`))
|
|
26422
|
+
: '';
|
|
26423
|
+
rem.push({ save: carriedTok, line: _bt(
|
|
26424
|
+
`이월분 ${carriedTok} tok (합계의 ${Math.round(carriedTok / total * 100)}%) 은 **등급을 낮춰도 남습니다** — AGENTS.md/CLAUDE.md 의 "Preserved previous content" 를 옮기세요${where}`,
|
|
26425
|
+
`carried-over content is ${carriedTok} tok (${Math.round(carriedTok / total * 100)}% of the total) and **survives a mode change** — move the "Preserved previous content" section out of AGENTS.md/CLAUDE.md${where}`) });
|
|
26426
|
+
}
|
|
26427
|
+
rem.sort((a, b) => b.save - a.save);
|
|
26428
|
+
for (const r of rem) log(_dm(` → ${r.line}`));
|
|
26429
|
+
if (!rem.length) log(_dm(_bt(` → 지침 파일과 handoff 출력을 줄이세요`, ` → trim the instruction files and handoff output`)));
|
|
26246
26430
|
} else {
|
|
26247
|
-
ok(`예산 이내 (${total}/${budget} tok)`);
|
|
26431
|
+
ok(_bt(`예산 이내 (${total}/${budget} tok)`, `within budget (${total}/${budget} tok)`));
|
|
26248
26432
|
}
|
|
26249
|
-
log(_dm(' ⓘ 토큰은 길이 기반 추정치입니다 — 절대값이 아니라 추세/비교용으로 쓰세요.'
|
|
26433
|
+
log(_dm(_bt(' ⓘ 토큰은 길이 기반 추정치입니다 — 절대값이 아니라 추세/비교용으로 쓰세요.',
|
|
26434
|
+
' ⓘ Token counts are length-based estimates — use them for trends and comparisons, not as absolute values.')));
|
|
26250
26435
|
return;
|
|
26251
26436
|
}
|
|
26252
26437
|
if (cmd === 'init') {
|
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/lib/pure-utils.js
CHANGED
|
@@ -946,9 +946,13 @@ function _mergeReadmeSection(existing, block, START, END) {
|
|
|
946
946
|
}
|
|
947
947
|
// 1.9.368 (UR-0025): 관리 파일 마이그레이션 머지 순수 코어 — 이전 내용을 migration-preserved 블록으로 보존(데이터/인덱스 파일은 overwrite).
|
|
948
948
|
// archiveRel(사전 계산된 표시 경로) + overwriteSet 을 인자로 주입 → path/process/상수 비결합(순수).
|
|
949
|
+
// 1.36.112: 이월 블록의 경계는 **쓰는 쪽과 읽는 쪽이 같은 상수**를 봐야 한다.
|
|
950
|
+
// 종전엔 이 태그가 _managedMerge 안의 지역 변수라 밖에서 재구현할 수밖에 없었고,
|
|
951
|
+
// 그 재구현이 1.36.88→90 에서 판정 불일치를 되살린 것과 같은 형태다(술어를 공유하지 않으면 표면마다 진실이 갈린다).
|
|
952
|
+
const PRESERVED_TAG = '<!-- leerness:migration-preserved -->';
|
|
949
953
|
function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}) {
|
|
950
954
|
if (!previous || previous.trim() === next.trim()) return next;
|
|
951
|
-
const tag =
|
|
955
|
+
const tag = PRESERVED_TAG;
|
|
952
956
|
if (overwriteSet && overwriteSet.has(String(file).replace(/\\/g, '/'))) return next;
|
|
953
957
|
// 1.36.60 (F-05 2회차, 검수 High): 언어 전환 시 구 언어 canonical 템플릿(altTemplate)의 라인도 차감 —
|
|
954
958
|
// 종전엔 KO→EN 재init 에서 KO 템플릿 전체가 "커스텀"으로 오인돼 Preserved 에 통째 이월됐다.
|
|
@@ -1011,6 +1015,43 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
|
|
|
1011
1015
|
return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\n${note}\n\n` + custom.join('\n') + '\n';
|
|
1012
1016
|
}
|
|
1013
1017
|
|
|
1018
|
+
// 1.36.112: 위가 쓴 이월 블록을 **되읽는** 짝. `context budget` 이 지침 파일을 관리분/이월분으로 가르는 데 쓴다.
|
|
1019
|
+
// 왜 필요한가: 이월분은 1.36.95·1.36.105 에서 두 번 "provenance 라운드로 미룬다" 며 남겨졌는데,
|
|
1020
|
+
// 그동안 **비용을 한 번도 재지 않았다**. 이 함수로 잰 실측(설치본 45개)에서 12개가 이월분을 갖고 합계 10,895 tok,
|
|
1021
|
+
// 최악(이 저장소)은 지침 파일의 78.0%(6,048 tok)이고, minimal 등급으로 낮춰도 적재 7,242 tok 중 83% 가 그것이다.
|
|
1022
|
+
// 여기서 하는 일은 **재는 것뿐**이다 — 지우지 않는다. 삭제를 막은 두 번의 판단(false-DROP 이 버그)은 그대로 유효하다.
|
|
1023
|
+
const _TAG_ESC = PRESERVED_TAG.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1024
|
+
// 경계는 **생성기가 쓰는 opener 전체**로만 확정한다. 태그 하나 또는 제목 하나로 정하면 두 가지를 오인한다
|
|
1025
|
+
// (검수 재현): (a) 사용자가 직접 쓴 `## Preserved previous content` 섹션 51B, (b) 문서에 인용된 태그 60B.
|
|
1026
|
+
// 그 뒤 전부가 '이월분' 으로 계상되고, 도구는 사용자에게 **자기 산문을 옮기라고** 권하게 된다.
|
|
1027
|
+
// 그래서 편향을 **과소 보고** 쪽에 둔다 — 못 세는 것보다 잘못 권하는 쪽이 나쁘다.
|
|
1028
|
+
const _OPENER_RE = new RegExp(`^[ \\t]*${_TAG_ESC}[ \\t\\r]*$\\r?\\n^#{1,3}[ \\t]*Preserved previous content[ \\t\\r]*$`, 'm');
|
|
1029
|
+
const _HEAD_RE = /^#{1,3}[ \t]*Preserved previous content[ \t\r]*$/gm;
|
|
1030
|
+
// 태그가 없는 구형 설치본은 제목 **뒤에 생성 안내문이 따라올 때만** 인정한다(제목만으로는 사용자 섹션과 구분 불가).
|
|
1031
|
+
const _NOTE_RE = /전체 원본 백업|Full original backup|Previous content was backed up|이전 버전에서 이어진|Custom user\/project content carried over/;
|
|
1032
|
+
function _splitPreserved(text) {
|
|
1033
|
+
const s = String(text == null ? '' : text);
|
|
1034
|
+
let at = -1;
|
|
1035
|
+
const m1 = _OPENER_RE.exec(s);
|
|
1036
|
+
if (m1) at = m1.index;
|
|
1037
|
+
if (at < 0) {
|
|
1038
|
+
_HEAD_RE.lastIndex = 0;
|
|
1039
|
+
let h;
|
|
1040
|
+
while ((h = _HEAD_RE.exec(s))) {
|
|
1041
|
+
if (_NOTE_RE.test(s.slice(h.index, h.index + 800))) { at = h.index; break; }
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (at < 0) return { managed: s, preserved: '', at: -1 };
|
|
1045
|
+
// 앞의 `---` 구분선도 생성물이라 이월분에 포함시킨다(관리분에 남기면 관리분이 그만큼 부풀어 보인다).
|
|
1046
|
+
// ⚠ 여기서 접두 **전체**를 정규식으로 훑으면 개행만 긴 입력에서 2차식이 된다 —
|
|
1047
|
+
// 검수 재현에서 개행 40만 개 입력이 52.8초였다(5만 0.6s → 40만 52.8s). 꼬리 64자만 본다.
|
|
1048
|
+
const before = s.slice(0, at);
|
|
1049
|
+
const tail = before.slice(-64);
|
|
1050
|
+
const hr = /(?:\r?\n)*---[ \t]*(?:\r?\n)+$/.exec(tail);
|
|
1051
|
+
const start = hr ? at - (tail.length - hr.index) : at;
|
|
1052
|
+
return { managed: s.slice(0, start), preserved: s.slice(start), at: start };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1014
1055
|
// 1.9.369 (UR-0025): --skills 값 파싱 순수 코어 — catalog 주입(harness skillCatalog 비결합). all/recommended/csv 처리 + catalog 필터.
|
|
1015
1056
|
function _parseSkillsValue(v, catalog) {
|
|
1016
1057
|
if (!v || v === true) return [];
|
|
@@ -1440,7 +1481,7 @@ module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
|
|
|
1440
1481
|
_matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
|
|
1441
1482
|
_isSecretKey, redactSecrets, hasCredentialMarker, compareVer, parseHarnessVersion,
|
|
1442
1483
|
_isPlaceholderSecret, _looksSecretLike,
|
|
1443
|
-
_mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue,
|
|
1484
|
+
_mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _splitPreserved, PRESERVED_TAG, _parseSkillsValue,
|
|
1444
1485
|
_parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine,
|
|
1445
1486
|
_classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
|
|
1446
1487
|
// 1.9.283 (UR-0025 2단계)
|
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
|
@@ -10580,7 +10580,9 @@ total++;
|
|
|
10580
10580
|
// 래칫: 지금 측정된 부채보다 **늘면 실패**. 줄어드는 것은 언제나 통과.
|
|
10581
10581
|
// 1.36.111 실측. 처음엔 21개 명령만 재서 46 이었는데, 검수 지적으로 커버리지를 39개로 넓히자 **111** 이 드러났다 —
|
|
10582
10582
|
// 좁은 목록의 기준선은 실제 부채보다 낙관적이었다. 줄인 만큼만 낮춘다(느슨하게 두면 그 안에서 조용히 썩는다).
|
|
10583
|
-
|
|
10583
|
+
// 1.36.112: 106 으로 조인다. `context budget` 을 손대는 김에 영어화해 5줄을 갚았다(111 → 106, 실측).
|
|
10584
|
+
// 갚은 만큼만 낮춘다 — 여유를 남기면 그 안에서 조용히 썩는다(1.36.82 의 자기참조 가드에서 겪은 형태).
|
|
10585
|
+
const BASELINE = 106;
|
|
10584
10586
|
dbg.baseline = BASELINE;
|
|
10585
10587
|
dbg.withinRatchet = leaky <= BASELINE;
|
|
10586
10588
|
// 이번 라운드가 고친 표면은 **0 이어야** 한다 — 래칫과 별개로 회귀를 직접 막는다.
|
|
@@ -10605,6 +10607,365 @@ total++;
|
|
|
10605
10607
|
if (!ok) failed++;
|
|
10606
10608
|
}
|
|
10607
10609
|
|
|
10610
|
+
// ── 1.36.112 블록 P: 이월분(Preserved previous content) 비용을 `context budget` 이 **분해해 보인다**.
|
|
10611
|
+
// 왜: 1.36.95 와 1.36.105 가 각각 "기존 설치본 정리는 provenance 라운드로", "오래된 이월을 줄이는 일은
|
|
10612
|
+
// 별도 라운드로" 미뤘다. 두 판단(삭제는 안전을 증명 못 한다 · false-DROP 이 버그)은 지금도 유효하다.
|
|
10613
|
+
// 미룬 두 번이 건너뛴 것은 **비용 측정**이다 — 설치본 45개 중 12개가 이월분을 갖고 합계 10,895 tok,
|
|
10614
|
+
// 최악(이 저장소)은 지침 파일의 78.0%(6,048 tok)이고, minimal 로 낮춰도 적재 7,242 tok 중 83% 가 그것이다.
|
|
10615
|
+
// (이 수치는 **출하되는 `_splitPreserved` 로** 다시 잰 값이다 — 분해기를 엄격하게 고친 뒤 재측정했다.)
|
|
10616
|
+
// 이 가드는 "재서 보인다" 만 지킨다. 지우는 동작은 여전히 없고, 그게 없다는 것도 아래에서 단언한다.
|
|
10617
|
+
{
|
|
10618
|
+
total++;
|
|
10619
|
+
let ok = false; const dbg = {};
|
|
10620
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-carry112-'));
|
|
10621
|
+
const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
|
|
10622
|
+
try {
|
|
10623
|
+
const mk = (name, lang) => {
|
|
10624
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
10625
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"proj","version":"0.1.0"}');
|
|
10626
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', lang || 'ko'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10627
|
+
return d;
|
|
10628
|
+
};
|
|
10629
|
+
const R = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: env || ENV });
|
|
10630
|
+
const J = (d, a, env) => { try { return JSON.parse(String((R(d, a.concat(['--json']), env)).stdout || '').trim()); } catch { return null; } };
|
|
10631
|
+
|
|
10632
|
+
// 대조군 ①: 이월분이 **없는** 프로젝트는 0 이어야 한다. 없는 부채를 보고하면 이 지표 자체를 못 믿는다.
|
|
10633
|
+
const clean = mk('clean');
|
|
10634
|
+
const cleanB = J(clean, ['context', 'budget']);
|
|
10635
|
+
dbg.cleanZero = !!cleanB && cleanB.carried && cleanB.carried.tokens === 0
|
|
10636
|
+
&& cleanB.parts.every(p => !p.carriedTokens)
|
|
10637
|
+
&& !/이월|carried-over/.test(String(R(clean, ['context', 'budget']).stdout || ''));
|
|
10638
|
+
|
|
10639
|
+
// 측정 대상 블록은 **실제 writer 로** 만든다. 손으로 흉내 내면 형식이 갈릴 때 가드가 조용히 죽는다.
|
|
10640
|
+
const PU = require(path.resolve(path.dirname(CLI), '..', 'lib', 'pure-utils'));
|
|
10641
|
+
const carried = mk('carried');
|
|
10642
|
+
const agp = path.join(carried, 'AGENTS.md');
|
|
10643
|
+
const base = fs.readFileSync(agp, 'utf8');
|
|
10644
|
+
const legacy = Array.from({ length: 150 }, (_, i) => `- LEGACY-CARRY-${i} 과거 릴리스 이력 항목 (1.9.${100 + i})`).join('\n');
|
|
10645
|
+
const merged = PU._managedMerge('AGENTS.md', base, base + '\n' + legacy + '\n', '.harness/archive', new Set(), {});
|
|
10646
|
+
dbg.writerMadeBlock = merged.includes(PU.PRESERVED_TAG) && merged.includes('LEGACY-CARRY-149');
|
|
10647
|
+
fs.writeFileSync(agp, merged);
|
|
10648
|
+
|
|
10649
|
+
// ② 분해기는 writer 가 쓴 것을 **한 바이트도 잃지 않고** 가른다 (관리분 + 이월분 === 원문)
|
|
10650
|
+
const sp = PU._splitPreserved(merged);
|
|
10651
|
+
dbg.roundTrip = (sp.managed + sp.preserved) === merged && sp.preserved.includes('LEGACY-CARRY-0')
|
|
10652
|
+
&& !sp.managed.includes('LEGACY-CARRY-0') && sp.preserved.startsWith('\n');
|
|
10653
|
+
// 이월 블록이 없는 입력은 통째로 관리분이다(경계 없는 곳에 경계를 만들지 않는다)
|
|
10654
|
+
const spNone = PU._splitPreserved(base);
|
|
10655
|
+
dbg.splitNoop = spNone.preserved === '' && spNone.managed === base && spNone.at === -1;
|
|
10656
|
+
|
|
10657
|
+
// ③ 실제로 센다 — 파일 라인과 carried 합계 양쪽에서
|
|
10658
|
+
const cb = J(carried, ['context', 'budget']);
|
|
10659
|
+
const agPart = cb && cb.parts.find(p => p.key === 'AGENTS.md');
|
|
10660
|
+
dbg.counts = !!cb && cb.carried.tokens > 500 && !!agPart && agPart.carriedTokens > 500
|
|
10661
|
+
&& cb.carried.tokens === agPart.carriedTokens && cb.carried.pctOfTotal > 0;
|
|
10662
|
+
|
|
10663
|
+
// ④ **이 라운드의 핵심 주장**: 이월분은 등급을 낮춰도 남는다. 주장을 출력만 하지 말고 측정으로 확인한다.
|
|
10664
|
+
R(carried, ['mode', 'set', 'minimal']);
|
|
10665
|
+
const mb = J(carried, ['context', 'budget']);
|
|
10666
|
+
dbg.survivesMode = !!mb && mb.mode === 'minimal' && mb.total < cb.total
|
|
10667
|
+
&& mb.carried.tokens === cb.carried.tokens && mb.carried.tokens > 0
|
|
10668
|
+
&& mb.carried.pctOfTotal > cb.carried.pctOfTotal; // 총량이 줄어 이월분의 **비중은 오히려 커진다**
|
|
10669
|
+
R(carried, ['mode', 'set', 'standard']);
|
|
10670
|
+
|
|
10671
|
+
// ⑤ 백업 존재 판정은 **양방향**으로 움직여야 한다. 한 방향만 보면 상수를 반환해도 통과한다.
|
|
10672
|
+
// (작성 중 실제로 오경보를 냈다 — 안내가 아카이브 '루트' 를 가리키는 형식을 상정하지 않아서였다.)
|
|
10673
|
+
const arch = path.join(carried, '.harness', 'archive', 'leerness-1.0.0-x', 'files');
|
|
10674
|
+
fs.mkdirSync(arch, { recursive: true }); fs.writeFileSync(path.join(arch, 'AGENTS.md'), base);
|
|
10675
|
+
const withArch = J(carried, ['context', 'budget']);
|
|
10676
|
+
fs.rmSync(path.join(carried, '.harness', 'archive'), { recursive: true, force: true });
|
|
10677
|
+
const noArch = J(carried, ['context', 'budget']);
|
|
10678
|
+
dbg.archiveBothWays = !!withArch && !!noArch
|
|
10679
|
+
&& withArch.carried.archive && withArch.carried.archive.exists === true
|
|
10680
|
+
&& noArch.carried.archive && noArch.carried.archive.exists === false;
|
|
10681
|
+
|
|
10682
|
+
// ⑥ 권고는 **실측 절감액 순**이다. 순서는 권고가 **둘 다 있을 때만** 판별되므로 양방향 픽스처를 쓴다.
|
|
10683
|
+
// 1차 작성 때 minimal 케이스만 뒀다가 "고정 순서로 되돌림" 변이가 살아남았다 — 권고가 하나뿐이면
|
|
10684
|
+
// 어떤 정렬을 써도 결과가 같다(정렬을 주장해 놓고 정렬을 시험하지 않은 공허한 커버리지).
|
|
10685
|
+
// ⑥-a 등급 절감 > 이월분 → 등급 권고가 먼저 (뒤집기 변이를 잡는다)
|
|
10686
|
+
// ⑥-b 이월분 > 등급 절감 → 이월분 권고가 먼저 (정렬 제거 변이를 잡는다 — 삽입 순서가 곧 옛 고정 순서다)
|
|
10687
|
+
const remLines = (d) => String(R(d, ['context', 'budget']).stdout || '').split('\n').filter(l => /^\s*→ /.test(l));
|
|
10688
|
+
fs.appendFileSync(path.join(carried, 'CLAUDE.md'), '\n' + 'x'.repeat(20000) + '\n'); // 관리분만 키운다(이월분 불변)
|
|
10689
|
+
const aStd = J(carried, ['context', 'budget']);
|
|
10690
|
+
const aRef = aStd && aStd.parts.find(p => p.key === 'referencedDocs');
|
|
10691
|
+
const aLines = remLines(carried);
|
|
10692
|
+
dbg.remedyOrderModeFirst = !!aStd && aStd.over === true && !!aRef
|
|
10693
|
+
&& aRef.tokens > aStd.carried.tokens // 판별 조건이 실제로 성립했는지 **먼저** 단언
|
|
10694
|
+
&& aLines.length === 2 && /mode set minimal/.test(aLines[0]) && /이월분/.test(aLines[1]);
|
|
10695
|
+
|
|
10696
|
+
const big = mk('big');
|
|
10697
|
+
const bagp = path.join(big, 'AGENTS.md');
|
|
10698
|
+
const bbase = fs.readFileSync(bagp, 'utf8');
|
|
10699
|
+
const huge = Array.from({ length: 1200 }, (_, i) => `- LEGACY-BIG-${i} 과거 릴리스 이력 항목 (1.9.${i})`).join('\n');
|
|
10700
|
+
fs.writeFileSync(bagp, PU._managedMerge('AGENTS.md', bbase, bbase + '\n' + huge + '\n', '.harness/archive', new Set(), {}));
|
|
10701
|
+
const bStd = J(big, ['context', 'budget']);
|
|
10702
|
+
const bRef = bStd && bStd.parts.find(p => p.key === 'referencedDocs');
|
|
10703
|
+
const bLines = remLines(big);
|
|
10704
|
+
dbg.remedyOrderCarriedFirst = !!bStd && bStd.over === true && !!bRef
|
|
10705
|
+
&& bStd.carried.tokens > bRef.tokens
|
|
10706
|
+
&& bLines.length === 2 && /이월분/.test(bLines[0]) && /mode set minimal/.test(bLines[1]);
|
|
10707
|
+
|
|
10708
|
+
// minimal 에서는 등급 권고의 절감이 0 이므로 이월분 권고만 남아야 한다
|
|
10709
|
+
// (종전엔 순서가 고정이라, 등급을 낮춰도 예산을 못 지키는 프로젝트에 헛수고를 먼저 권했다).
|
|
10710
|
+
R(carried, ['mode', 'set', 'minimal']);
|
|
10711
|
+
const minOut = String(R(carried, ['context', 'budget']).stdout || '');
|
|
10712
|
+
const lines = minOut.split('\n').filter(l => /^\s*→ /.test(l));
|
|
10713
|
+
dbg.remedyOrder = lines.length === 1 && /이월분/.test(lines[0]) && !lines.some(l => /mode set minimal/.test(l));
|
|
10714
|
+
dbg.remedyQuantified = /이월분 \d+ tok \(합계의 \d+%\) 은 \*\*등급을 낮춰도 남습니다\*\*/.test(minOut);
|
|
10715
|
+
|
|
10716
|
+
// ⑦ 이 라운드는 **지우지 않는다**. 이월 블록이 그대로 남아 있어야 한다(측정이 삭제로 번지지 않았음을 단언).
|
|
10717
|
+
dbg.noDeletion = fs.readFileSync(path.join(carried, 'AGENTS.md'), 'utf8').includes('LEGACY-CARRY-149');
|
|
10718
|
+
|
|
10719
|
+
// ⑧ 영어 모드에서 이 표면은 한글을 내지 않는다(T-0100 상환분 — 래칫 여유에 숨지 못하게 직접 단언).
|
|
10720
|
+
// ⚠ 종전엔 **예산 이내** 신규 프로젝트만 봐서 `if (over)` 안의 영어 문구 6종이 한 번도 렌더되지 않았다
|
|
10721
|
+
// (독립 검수 지적). 한국어가 되살아나기 가장 쉬운 절반을 가드가 못 보고 있었다 — 초과·이월 분기를 실제로 밟힌다.
|
|
10722
|
+
const en = mk('en_carry', 'en');
|
|
10723
|
+
const enEnv = Object.assign({}, ENV, { LEERNESS_LANG: 'en' });
|
|
10724
|
+
{
|
|
10725
|
+
const p = path.join(en, 'AGENTS.md'); const t = fs.readFileSync(p, 'utf8');
|
|
10726
|
+
const extra = Array.from({ length: 200 }, (_, i) => `- CARRY-EN-${i} legacy release note`).join('\n');
|
|
10727
|
+
fs.writeFileSync(p, PU._managedMerge('AGENTS.md', t, t + '\n' + extra + '\n', '.harness/archive', new Set(), { lang: 'en' }));
|
|
10728
|
+
fs.appendFileSync(path.join(en, 'CLAUDE.md'), '\n' + 'x'.repeat(40000) + '\n');
|
|
10729
|
+
}
|
|
10730
|
+
const enOut = String(R(en, ['context', 'budget'], enEnv).stdout || '');
|
|
10731
|
+
const enJ = J(en, ['context', 'budget'], enEnv);
|
|
10732
|
+
// 손상 경고까지 영어여야 한다 — 사유 문자열이 한국어면 그 줄은 여전히 누출이다(별도 프로젝트로 분리 측정).
|
|
10733
|
+
const enCorrupt = mk('en_corrupt', 'en');
|
|
10734
|
+
fs.writeFileSync(path.join(enCorrupt, '.harness', 'manifest.json'), '{ not json');
|
|
10735
|
+
const enCorruptOut = String(R(enCorrupt, ['context', 'budget'], enEnv).stdout || '');
|
|
10736
|
+
dbg.enClean = enOut.split('\n').filter(l => /[가-힣ㄱ-ㆎ]/.test(l)).length === 0
|
|
10737
|
+
&& enCorruptOut.split('\n').filter(l => /[가-힣ㄱ-ㆎ]/.test(l)).length === 0
|
|
10738
|
+
&& /manifest\.json corrupt \(invalid JSON\)/.test(enCorruptOut) // 손상 분기를 실제로 밟았는지 단언
|
|
10739
|
+
&& !!enJ && enJ.lang === 'en'
|
|
10740
|
+
&& enJ.over === true && enJ.carried.tokens > 0 // 초과·이월 분기를 실제로 밟았는지 단언
|
|
10741
|
+
&& /carried-over/.test(enOut) && /survives a mode change/.test(enOut)
|
|
10742
|
+
&& /at least/.test(enOut) && /session load exceeds the budget/.test(enOut)
|
|
10743
|
+
&& enJ.parts.every(p => typeof p.key === 'string'); // 기계는 로케일 흔들리는 라벨 대신 안정 키를 본다
|
|
10744
|
+
|
|
10745
|
+
// ⑨ 두 파일이 **모두** 이월분을 가진 형태 — 이 라운드의 근거 수치(77.6%)가 나온 저장소가 바로 이 형태다.
|
|
10746
|
+
// 종전 픽스처는 AGENTS.md 에만 넣어 **합산이 한 번도 시험되지 않았고**, 독립 검수가
|
|
10747
|
+
// `carriedTok = Math.max(carriedTok, cT)` 변이의 생존을 지적했다(합계가 최댓값과 구별되지 않았다).
|
|
10748
|
+
const both = mk('both');
|
|
10749
|
+
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
|
|
10750
|
+
const p = path.join(both, f); const t = fs.readFileSync(p, 'utf8');
|
|
10751
|
+
const extra = Array.from({ length: f === 'AGENTS.md' ? 140 : 55 }, (_, i) => `- CARRY-${f}-${i} 과거 항목`).join('\n');
|
|
10752
|
+
fs.writeFileSync(p, PU._managedMerge(f, t, t + '\n' + extra + '\n', '.harness/archive', new Set(), {}));
|
|
10753
|
+
}
|
|
10754
|
+
const twoJ = J(both, ['context', 'budget']);
|
|
10755
|
+
const pA = twoJ && twoJ.parts.find(p => p.key === 'AGENTS.md');
|
|
10756
|
+
const pC = twoJ && twoJ.parts.find(p => p.key === 'CLAUDE.md');
|
|
10757
|
+
dbg.twoFileSum = !!twoJ && !!pA && !!pC && pA.carriedTokens > 0 && pC.carriedTokens > 0
|
|
10758
|
+
&& pA.carriedTokens !== pC.carriedTokens // 합계와 최댓값이 갈리는 입력인지 **먼저** 확인
|
|
10759
|
+
&& twoJ.carried.tokens === pA.carriedTokens + pC.carriedTokens
|
|
10760
|
+
&& twoJ.carried.bytes > twoJ.carried.tokens; // bytes 가 tokens 의 복사본이 아님(계약 필드 커버리지)
|
|
10761
|
+
|
|
10762
|
+
// ⑩ 백업 판정은 이월분을 가진 **모든** 파일을 본다. AGENTS 만 백업된 상태에서 "보관돼 있습니다" 는 거짓이다.
|
|
10763
|
+
const snap2 = path.join(both, '.harness', 'archive', 'leerness-1.0.0-x', 'files');
|
|
10764
|
+
fs.mkdirSync(snap2, { recursive: true }); fs.writeFileSync(path.join(snap2, 'AGENTS.md'), 'x');
|
|
10765
|
+
const partial = J(both, ['context', 'budget']);
|
|
10766
|
+
fs.writeFileSync(path.join(snap2, 'CLAUDE.md'), 'x');
|
|
10767
|
+
const full = J(both, ['context', 'budget']);
|
|
10768
|
+
dbg.archivePerFile = !!partial && !!full && !!partial.carried.archive && !!full.carried.archive
|
|
10769
|
+
&& partial.carried.archive.exists === false && full.carried.archive.exists === true
|
|
10770
|
+
&& full.carried.archive.files === 2;
|
|
10771
|
+
|
|
10772
|
+
// ⑪ 프로젝트 **밖**을 가리키는 안내는 백업으로 인정하지 않는다(안내 문구는 프로젝트 파일에서 온 입력이다)
|
|
10773
|
+
const esc = mk('esc');
|
|
10774
|
+
{
|
|
10775
|
+
const outside = path.join(sb, 'outside', 'files'); fs.mkdirSync(outside, { recursive: true });
|
|
10776
|
+
fs.writeFileSync(path.join(outside, 'AGENTS.md'), 'x');
|
|
10777
|
+
const p = path.join(esc, 'AGENTS.md'); const t = fs.readFileSync(p, 'utf8');
|
|
10778
|
+
fs.writeFileSync(p, PU._managedMerge('AGENTS.md', t, t + '\n- CARRY-ESC\n', '.harness/archive', new Set(), {})
|
|
10779
|
+
.replace('`.harness/archive`', '`../outside`'));
|
|
10780
|
+
}
|
|
10781
|
+
const escJ = J(esc, ['context', 'budget']);
|
|
10782
|
+
dbg.noEscape = !!escJ && !!escJ.carried.archive && escJ.carried.archive.exists === false;
|
|
10783
|
+
|
|
10784
|
+
// ⑫ 이월 블록 **안의** `.harness/*.md` 지목은 등급 절감으로 치지 않는다 — 등급을 낮춰도 그 언급은 남기 때문이다.
|
|
10785
|
+
// 독립 검수가 이 머신의 실제 설치본(_bench/v19-demo)에서 옛 문서 목록이 통째로 이월된 형태를 확인했다.
|
|
10786
|
+
// 구분하지 않으면 "등급을 낮추면 N tok 이 빠진다" 가 최악의 경우 **전부 허수**가 된다.
|
|
10787
|
+
const refc = mk('refcarry');
|
|
10788
|
+
{
|
|
10789
|
+
const p = path.join(refc, 'AGENTS.md');
|
|
10790
|
+
const stripped = fs.readFileSync(p, 'utf8').replace(/\.harness\//g, 'dot-harness/'); // 관리분에서 지목 제거
|
|
10791
|
+
fs.writeFileSync(p, PU._managedMerge('AGENTS.md', stripped,
|
|
10792
|
+
stripped + '\n- 읽어라: .harness/plan.md\n- 읽어라: .harness/decisions.md\n', '.harness/archive', new Set(), {}));
|
|
10793
|
+
fs.appendFileSync(path.join(refc, 'CLAUDE.md'), '\n' + 'x'.repeat(60000) + '\n'); // 예산 초과 유도
|
|
10794
|
+
}
|
|
10795
|
+
const refJ = J(refc, ['context', 'budget']);
|
|
10796
|
+
const refPart = refJ && refJ.parts.find(p => p.key === 'referencedDocs');
|
|
10797
|
+
const refLines = remLines(refc);
|
|
10798
|
+
// 판별 대조군: **같은 지목**을 관리분에 둔 쌍둥이 프로젝트. 두 값의 차이가 정확히 지목 토큰이어야 한다.
|
|
10799
|
+
// ⚠ 표시 문자열만 단언하면 공허하다 — 변이가 **정렬에 쓰는 값**만 바꾸면 화면은 그대로다(변이 M18 이 그렇게 생존했다).
|
|
10800
|
+
// ⚠ "절감 < 지목" 같은 크기 가정도 못 쓴다 — 신규 프로젝트에서는 그 관계가 성립하지 않는다(작성 중 실측).
|
|
10801
|
+
const refm = mk('refmanaged');
|
|
10802
|
+
{
|
|
10803
|
+
const p = path.join(refm, 'AGENTS.md');
|
|
10804
|
+
const stripped = fs.readFileSync(p, 'utf8').replace(/\.harness\//g, 'dot-harness/');
|
|
10805
|
+
const withRefs = stripped + '\n- 읽어라: .harness/plan.md\n- 읽어라: .harness/decisions.md\n';
|
|
10806
|
+
fs.writeFileSync(p, PU._managedMerge('AGENTS.md', withRefs, withRefs + '\n- CARRY-FILLER\n', '.harness/archive', new Set(), {}));
|
|
10807
|
+
fs.appendFileSync(path.join(refm, 'CLAUDE.md'), '\n' + 'x'.repeat(60000) + '\n');
|
|
10808
|
+
}
|
|
10809
|
+
const refmJ = J(refm, ['context', 'budget']);
|
|
10810
|
+
const refmPart = refmJ && refmJ.parts.find(p => p.key === 'referencedDocs');
|
|
10811
|
+
dbg.carriedRefsNotCounted = !!refJ && refJ.over === true
|
|
10812
|
+
&& !!refPart && refPart.tokens > 0 // 총량에는 들어간다 — AI 는 이월 블록도 읽는다
|
|
10813
|
+
&& typeof refJ.modeSavesAtLeast === 'number' && !!refmJ && typeof refmJ.modeSavesAtLeast === 'number'
|
|
10814
|
+
&& !!refmPart && refmPart.tokens === refPart.tokens // 두 프로젝트가 같은 문서를 가리키는지 먼저 확인
|
|
10815
|
+
&& refmJ.modeSavesAtLeast - refJ.modeSavesAtLeast === refPart.tokens
|
|
10816
|
+
&& refLines.some(l => /지목 문서 0 /.test(l)); // 표시도 0 이어야 한다
|
|
10817
|
+
|
|
10818
|
+
// ⑬ 관리 영역에 **사용자가 덧붙인** 내용은 등급을 낮춰도 삭제되지 않고 이월 블록으로 옮겨간다(managedMerge 의 계약).
|
|
10819
|
+
// 따라서 절감으로 세면 안 된다. 처음엔 `관리분 − 최소템플릿` 으로 재서 이 몫이 절감에 섞여 있었다.
|
|
10820
|
+
const pad = mk('padded');
|
|
10821
|
+
const padBefore = J(pad, ['context', 'budget']);
|
|
10822
|
+
fs.appendFileSync(path.join(pad, 'AGENTS.md'), '\n' + '사용자가 덧붙인 지침 줄입니다.\n'.repeat(2000));
|
|
10823
|
+
const padAfter = J(pad, ['context', 'budget']);
|
|
10824
|
+
const padA = padBefore && padBefore.parts.find(p => p.key === 'AGENTS.md');
|
|
10825
|
+
const padB = padAfter && padAfter.parts.find(p => p.key === 'AGENTS.md');
|
|
10826
|
+
dbg.userContentNotSaving = !!padA && !!padB
|
|
10827
|
+
&& padB.tokens - padA.tokens > 5000 // 덧붙임이 실제로 컸는지 먼저 확인
|
|
10828
|
+
&& padAfter.modeSavesAtLeast === padBefore.modeSavesAtLeast; // 그런데 절감은 그대로여야 한다
|
|
10829
|
+
|
|
10830
|
+
ok = dbg.cleanZero && dbg.writerMadeBlock && dbg.roundTrip && dbg.splitNoop && dbg.counts
|
|
10831
|
+
&& dbg.survivesMode && dbg.archiveBothWays && dbg.remedyOrder && dbg.remedyQuantified
|
|
10832
|
+
&& dbg.remedyOrderModeFirst && dbg.remedyOrderCarriedFirst
|
|
10833
|
+
&& dbg.twoFileSum && dbg.archivePerFile && dbg.noEscape && dbg.carriedRefsNotCounted
|
|
10834
|
+
&& dbg.userContentNotSaving && dbg.noDeletion && dbg.enClean;
|
|
10835
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
10836
|
+
console.log(ok ? '✓ P(1.36.112) 이월분 계량: 무이월 0 · 분해 무손실(왕복) · 경계없음 무동작 · 두 파일 합산(≠최댓값) · bytes 계약 · **등급을 낮춰도 잔존**(비중은 증가) · 백업판정 양방향+파일별+경로가둠 · 이월 안의 지목은 절감 아님 · 권고 절감액순(양방향 판별) + 수치동반 · 삭제 없음 · 영어표면 한글 0'
|
|
10837
|
+
: '✗ 1.36.112 이월분 계량 실패 ' + JSON.stringify(dbg));
|
|
10838
|
+
if (!ok) failed++;
|
|
10839
|
+
}
|
|
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
|
+
|
|
10608
10969
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
10609
10970
|
if (failed > 0) process.exit(1);
|
|
10610
10971
|
|