leerness 1.9.16 → 1.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.19 — 2026-05-14
4
+
5
+ **1.9.18 후속 다듬기 — verify-claim에 동적 실행, --strict-elements 정확도 강화**.
6
+
7
+ 1.9.18을 실전 sub-agent 검수에 쓰면서 발견한 두 가지 가공할 점을 마저 보완.
8
+
9
+ ### Added
10
+
11
+ - **`leerness verify-claim --run-tests`**: 정적 점검(파일 존재 + 테스트 카운트)에 더해 `npm test`를 동적으로 실행. stdout에서 `X/Y passed` 패턴을 파싱해 evidence 주장과 비교. 주장이 `5/5 통과`인데 실제 `3/5`면 exit 1. `--json`에 `run.parsed`, `verdict.declaredPassMatches` 포함.
12
+ - **`--strict-elements` 출력 강화**: 같은 함수명이라도 (a) 같은 파일이면 `⚠ 진짜 중복 가능`, (b) 다른 파일이면 `ℹ 의도 분리 가능` (예: 모듈 함수 vs CLI 명령)으로 분류. 1.9.18의 평면 출력보다 false positive 식별이 쉬워짐.
13
+
14
+ ### Why
15
+ - `verify-claim`만으로는 "파일이 있고 check() 호출이 많다" 정도까지만 보장. `--run-tests`가 추가되면 메인 에이전트가 sub-agent의 evidence를 **한 번의 명령으로 정적+동적 모두 검증**.
16
+ - 1.9.18 `--strict-elements`가 city-insights의 `MemoStats`/`StatsCli`(둘 다 `stats()` 함수, 다른 파일) 같은 의도된 분리를 잠재 중복으로 평면 표시 → 사용자가 직접 판별해야 했음. 1.9.19에선 정보를 더 줘서 즉시 분류 가능.
17
+
18
+ ### Migration
19
+ ```bash
20
+ npx leerness@latest update . --yes
21
+ ```
22
+
23
+ ## 1.9.18 — 2026-05-14
24
+
25
+ **오케스트레이션 검수 패키지 — `--since` 시간 필터 + `--strict-elements` 잠재 중복 + `depends-on` 그래프 + `verify-claim` 자동 검증**.
26
+
27
+ 1.9.17의 워크스페이스 모드를 실전 멀티 에이전트 작업에서 사용하다 발견한 4가지 갭을 모두 보완합니다. 검수 자동화에 초점.
28
+
29
+ ### Added
30
+
31
+ - **`leerness handoff --since <duration>`**: `24h` / `3d` / `1w` / `30m` 형식. 해당 기간 내 수정된 T-row에 🆕 마크 + 별도 "최근 변경" 섹션. sub-agent들이 방금 무엇을 추가했는지 한눈에.
32
+ - **`leerness reuse-map --strict-elements`**: element 컬럼에서 함수명 추출 (`src/build.js (escapeHtml)` → `escapeHtml`), **다른 capability 이름인데 같은 함수**를 잠재 중복으로 감지. 명명 일관성 검사용.
33
+ - **`reuse-map` depends-on 표기**: notes 컬럼에 `depends-on: A, B` 표기 시 자동 추출해 의존 그래프로 표시. 단일/워크스페이스 모두 지원. JSON에 `dependsEdges` 포함.
34
+ - **`leerness verify-claim <T-ID>`**: progress-tracker의 evidence 컬럼 자동 파싱 — 주장한 파일 경로 존재 확인, 주장한 테스트 수 vs 실제 `check()/test()/it()` 호출 수 대조. 불일치 시 `exit 1`. CI 통합용 `--json`.
35
+
36
+ ### Why
37
+ 멀티 에이전트 병렬 작업 검수 시 메인 에이전트가 매번 수동으로 `wc -l`, `grep`, `npm test`를 돌리고 있었음. 1.9.18은 그 패턴을 하나의 명령으로 자동화:
38
+ - "지금 sub-agent들이 뭘 추가했지?" → `handoff --all-apps --since 1h`
39
+ - "같은 함수를 두 번 만든 거 아닌가?" → `reuse-map --all-apps --strict-elements`
40
+ - "이 에이전트의 evidence가 진짜인가?" → `verify-claim T-0008`
41
+
42
+ ### Migration
43
+ ```bash
44
+ npx leerness@latest update . --yes
45
+ ```
46
+
47
+ ## 1.9.17 — 2026-05-14
48
+
49
+ **워크스페이스 오케스트레이션 — `handoff --all-apps` + `reuse-map --all-apps` + 중복 capability 감지**.
50
+
51
+ 멀티 에이전트 병렬 작업 시 메인 에이전트가 한 번의 명령으로 모든 sub-agent의 진행 상태를 파악하고, 새 패턴이 다른 프로젝트와 중복되는지 즉시 검증할 수 있습니다.
52
+
53
+ ### Added
54
+
55
+ - **`leerness handoff --all-apps` / `--include`**: 워크스페이스 전체의 진행 상태(WIP/blocked/다음 작업)를 한 화면에 출력. 4개 sub-agent가 병렬로 일할 때 메인 agent의 상황 인식용. `--json`도 지원.
56
+ - **`leerness reuse-map [path]`**: 단일 프로젝트의 reuse-map.md 파싱 출력.
57
+ - **`leerness reuse-map --all-apps` / `--include`**: 다수 프로젝트의 모든 capability를 모아 **동일 이름 capability를 자동 중복 감지**. 재사용/공통 모듈 추출 기회 식별. `--json`도 지원.
58
+
59
+ ### Why
60
+ 1.9.16까지의 `retro --all-apps`는 누적 회고용. 1.9.17은 **실시간 오케스트레이션용**: "지금 어떤 에이전트가 무엇을 하고 있고, 새 패턴이 다른 프로젝트와 겹치는가?"에 답합니다.
61
+
62
+ ### Migration
63
+ ```bash
64
+ npx leerness@latest update . --yes
65
+ ```
66
+
3
67
  ## 1.9.16 — 2026-05-13
4
68
 
5
69
  **brainstorm 워크스페이스 통합 + 3 명령 JSON export + session close 워크스페이스 안내**.
package/bin/harness.js CHANGED
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const readline = require('readline');
8
8
 
9
- const VERSION = '1.9.16';
9
+ const VERSION = '1.9.19';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -113,7 +113,7 @@ function arg(name, def = null) { const i = process.argv.indexOf(name); return i
113
113
  function has(name) { return process.argv.includes(name); }
114
114
  function nonFlagArgs() {
115
115
  const out = [];
116
- const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap']);
116
+ const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since']);
117
117
  const a = process.argv.slice(2);
118
118
  for (let i = 0; i < a.length; i++) {
119
119
  const x = a[i];
@@ -1200,6 +1200,430 @@ function handoff(root) {
1200
1200
  ok('handoff loaded; current-state updated');
1201
1201
  }
1202
1202
 
1203
+ // 1.9.18: --since 파서 ("24h", "3d", "1w", "30m") → cutoff ISO date
1204
+ function _parseSince(s) {
1205
+ if (!s) return null;
1206
+ const m = String(s).match(/^(\d+(?:\.\d+)?)\s*([mhdw])$/i);
1207
+ if (!m) return null;
1208
+ const n = parseFloat(m[1]);
1209
+ const unit = m[2].toLowerCase();
1210
+ const ms = unit === 'm' ? n * 60 * 1000
1211
+ : unit === 'h' ? n * 3600 * 1000
1212
+ : unit === 'd' ? n * 86400 * 1000
1213
+ : /* w */ n * 7 * 86400 * 1000;
1214
+ const cutoff = new Date(Date.now() - ms);
1215
+ return cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
1216
+ }
1217
+
1218
+ // 1.9.17→1.9.18: 워크스페이스 통합 handoff — 4개 agent 동시 작업 시 메인 agent용 한 줄 요약
1219
+ // 1.9.18: --since <duration> 추가, 최근 수정된 T-row 강조 (🆕 마크 + 별도 섹션)
1220
+ function _handoffWorkspace(rootBase) {
1221
+ const paths = _collectWorkspacePaths(rootBase);
1222
+ if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
1223
+ const sinceArg = arg('--since', null);
1224
+ const sinceCutoff = sinceArg ? _parseSince(sinceArg) : null;
1225
+ if (sinceArg && !sinceCutoff) { fail(`--since 형식 오류: "${sinceArg}" (예: 24h, 3d, 1w)`); return process.exit(1); }
1226
+
1227
+ function isRecent(row) {
1228
+ if (!sinceCutoff || !row.updated) return false;
1229
+ return row.updated >= sinceCutoff;
1230
+ }
1231
+
1232
+ if (has('--json')) {
1233
+ const projects = paths.map(p => {
1234
+ const rows = readProgressRows(p);
1235
+ const buckets = {};
1236
+ for (const r of rows) (buckets[r.status] || (buckets[r.status] = [])).push(r);
1237
+ const activeRules = readRules(p).filter(r => r.status === 'active').length;
1238
+ const recent = sinceCutoff ? rows.filter(isRecent) : [];
1239
+ return {
1240
+ project: path.basename(p),
1241
+ path: p,
1242
+ total: rows.length,
1243
+ done: (buckets['done'] || []).length,
1244
+ inProgress: (buckets['in-progress'] || []).length,
1245
+ planned: (buckets['planned'] || []).length,
1246
+ blocked: (buckets['blocked'] || []).length,
1247
+ activeRules,
1248
+ nextAction: (buckets['in-progress']?.[0]?.nextAction) || (buckets['planned']?.[0]?.nextAction) || (buckets['requested']?.[0]?.nextAction) || null,
1249
+ recent: recent.map(r => ({ id: r.id, status: r.status, request: r.request, updated: r.updated }))
1250
+ };
1251
+ });
1252
+ log(JSON.stringify({ workspace: path.basename(rootBase), since: sinceCutoff, projects, totals: {
1253
+ tasks: projects.reduce((a, b) => a + b.total, 0),
1254
+ done: projects.reduce((a, b) => a + b.done, 0),
1255
+ inProgress: projects.reduce((a, b) => a + b.inProgress, 0),
1256
+ blocked: projects.reduce((a, b) => a + b.blocked, 0),
1257
+ recent: projects.reduce((a, b) => a + (b.recent?.length || 0), 0)
1258
+ } }, null, 2));
1259
+ return;
1260
+ }
1261
+ log(`# Workspace Handoff — ${paths.length}개 프로젝트 (1.9.18)`);
1262
+ log(`Date: ${today()}`);
1263
+ if (sinceCutoff) log(`Filter: since ${sinceArg} (${sinceCutoff} 이후 수정된 항목 🆕 강조)`);
1264
+ log('');
1265
+ log('## 프로젝트별 진행 상태');
1266
+ let totalDone = 0, totalTasks = 0, totalWIP = 0, totalBlocked = 0, totalRecent = 0;
1267
+ const allRecent = [];
1268
+ for (const p of paths) {
1269
+ const rows = readProgressRows(p);
1270
+ const buckets = {};
1271
+ for (const r of rows) (buckets[r.status] || (buckets[r.status] = [])).push(r);
1272
+ const done = (buckets['done'] || []).length;
1273
+ const wip = (buckets['in-progress'] || []).length;
1274
+ const planned = (buckets['planned'] || []).length;
1275
+ const blocked = (buckets['blocked'] || []).length;
1276
+ const recent = sinceCutoff ? rows.filter(isRecent) : [];
1277
+ totalDone += done; totalTasks += rows.length; totalWIP += wip; totalBlocked += blocked; totalRecent += recent.length;
1278
+ for (const r of recent) allRecent.push({ project: path.basename(p), ...r });
1279
+ const nx = (buckets['in-progress']?.[0]) || (buckets['planned']?.[0]) || null;
1280
+ const pct = rows.length ? Math.round(done / rows.length * 100) : 0;
1281
+ const recentBadge = recent.length ? ` · 🆕 ${recent.length}` : '';
1282
+ log(` ${path.basename(p)}: ${done}/${rows.length} (${pct}%) · WIP ${wip} · planned ${planned}${blocked ? ` · 🚫 blocked ${blocked}` : ''}${recentBadge}`);
1283
+ if (nx) log(` └ 다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request}`);
1284
+ }
1285
+ // 1.9.18: --since 모드일 때 최근 추가/수정 섹션
1286
+ if (sinceCutoff) {
1287
+ log('');
1288
+ log(`## 🆕 최근 변경 (${sinceArg} 내, ${totalRecent}건)`);
1289
+ if (!totalRecent) log(` (없음) — ${sinceCutoff} 이후 progress-tracker 업데이트 없음`);
1290
+ else {
1291
+ for (const r of allRecent) log(` - ${r.project}/${r.id} [${r.status}] ${r.request} (updated ${r.updated})`);
1292
+ }
1293
+ }
1294
+ log('');
1295
+ log(`## 📊 워크스페이스 총합`);
1296
+ log(` - 누적 task: ${totalTasks} (done ${totalDone}, ${totalTasks ? Math.round(totalDone / totalTasks * 100) : 0}%)`);
1297
+ log(` - 진행중 (WIP): ${totalWIP} · 차단: ${totalBlocked}${sinceCutoff ? ` · 🆕 최근 ${totalRecent}` : ''}`);
1298
+ if (totalBlocked > 0) log(` - ⚠ ${totalBlocked}건이 blocked — 우선 처리 검토`);
1299
+ log('');
1300
+ log(`## 💡 멀티에이전트 오케스트레이션 권장`);
1301
+ log(` - 각 프로젝트의 "다음" 작업을 sub-agent 1명씩 병렬 진행 가능`);
1302
+ log(` - 새 패턴 추가 시 \`leerness reuse-map --all-apps\`로 중복 감지${sinceCutoff ? '' : ' / `--since 24h`로 최근 변경 추적'}`);
1303
+ }
1304
+
1305
+ function handoffCmd(root) {
1306
+ // 1.9.17: --all-apps / --include 통합 모드
1307
+ if (has('--all-apps') || arg('--include', null)) {
1308
+ return _handoffWorkspace(absRoot(root));
1309
+ }
1310
+ return handoff(root);
1311
+ }
1312
+
1313
+ // 1.9.17: 워크스페이스 통합 reuse-map — Capability 중복 자동 감지
1314
+ // 1.9.18: element에서 함수명 추출, notes에서 depends-on 추출
1315
+ function _extractFunctionName(element) {
1316
+ // "src/build.js (escapeHtml)" → "escapeHtml"
1317
+ // "src/openMeteo.js (fetchBatch)" → "fetchBatch"
1318
+ // "src/cities.js" → null
1319
+ const m = String(element).match(/\(([A-Za-z_$][\w$]*)\s*\)?\s*$/);
1320
+ return m ? m[1] : null;
1321
+ }
1322
+ function _extractFilePath(element) {
1323
+ // "src/build.js (escapeHtml)" → "src/build.js"
1324
+ // "src/cities.js" → "src/cities.js"
1325
+ const m = String(element).match(/^([^\s(]+)/);
1326
+ return m ? m[1] : null;
1327
+ }
1328
+ function _extractDependsOn(notes) {
1329
+ // notes 컬럼에서 "depends-on: A, B" 또는 "depends: A" 패턴 추출
1330
+ const m = String(notes).match(/depends(?:-on)?:\s*([^|]+?)(?:\s*\)|$)/i);
1331
+ if (!m) return [];
1332
+ return m[1].split(/[,;]/).map(s => s.trim()).filter(Boolean);
1333
+ }
1334
+
1335
+ function _readReuseMap(root) {
1336
+ const p = path.join(root, '.harness', 'reuse-map.md');
1337
+ if (!exists(p)) return [];
1338
+ const txt = read(p);
1339
+ const lines = txt.split('\n');
1340
+ const out = [];
1341
+ for (let i = 0; i < lines.length; i++) {
1342
+ const l = lines[i].trim();
1343
+ // skip header + separator + empty
1344
+ if (!l.startsWith('|') || l.startsWith('|--') || /^\|\s*Capability\s*\|/i.test(l)) continue;
1345
+ const cells = l.split('|').map(c => c.trim()).filter((_, idx, arr) => idx !== 0 && idx !== arr.length - 1);
1346
+ if (cells.length < 2 || !cells[0]) continue;
1347
+ const notes = cells[3] || '';
1348
+ out.push({
1349
+ capability: cells[0],
1350
+ element: cells[1] || '',
1351
+ method: cells[2] || '',
1352
+ notes,
1353
+ line: i + 1,
1354
+ // 1.9.18: 파생 필드
1355
+ functionName: _extractFunctionName(cells[1] || ''),
1356
+ filePath: _extractFilePath(cells[1] || ''),
1357
+ dependsOn: _extractDependsOn(notes)
1358
+ });
1359
+ }
1360
+ return out;
1361
+ }
1362
+
1363
+ function reuseMapCmd(root) {
1364
+ root = absRoot(root || process.cwd());
1365
+ // 단일 프로젝트 모드
1366
+ if (!has('--all-apps') && !arg('--include', null)) {
1367
+ const entries = _readReuseMap(root);
1368
+ if (has('--json')) { log(JSON.stringify({ project: path.basename(root), entries }, null, 2)); return; }
1369
+ log(`# Reuse Map — ${path.basename(root)} (${entries.length}개)`);
1370
+ if (!entries.length) { log(' (없음) — 새 컴포넌트/유틸 추가 후 등록 권장'); return; }
1371
+ entries.forEach(e => {
1372
+ const dep = e.dependsOn.length ? ` ← depends: ${e.dependsOn.join(', ')}` : '';
1373
+ log(` - ${e.capability} → ${e.element} [${e.method}] ${e.notes}${dep}`);
1374
+ });
1375
+ return;
1376
+ }
1377
+ // 워크스페이스 모드
1378
+ const paths = _collectWorkspacePaths(root);
1379
+ if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
1380
+ const strictElements = has('--strict-elements');
1381
+ const byCap = new Map(); // capability(lowercase) → [{ project, entry }]
1382
+ const byFunc = new Map(); // functionName → [{ project, entry }] // 1.9.18
1383
+ const dependsEdges = []; // 1.9.18: { from: {project, cap}, to: cap }
1384
+ const projects = paths.map(p => {
1385
+ const entries = _readReuseMap(p);
1386
+ for (const e of entries) {
1387
+ const k = e.capability.toLowerCase().trim();
1388
+ if (!byCap.has(k)) byCap.set(k, []);
1389
+ byCap.get(k).push({ project: path.basename(p), path: p, entry: e });
1390
+ // 1.9.18: function-name 인덱스
1391
+ if (e.functionName) {
1392
+ const fk = e.functionName.toLowerCase();
1393
+ if (!byFunc.has(fk)) byFunc.set(fk, []);
1394
+ byFunc.get(fk).push({ project: path.basename(p), path: p, entry: e });
1395
+ }
1396
+ // 1.9.18: depends-on 엣지
1397
+ for (const dep of e.dependsOn) {
1398
+ dependsEdges.push({ from: { project: path.basename(p), cap: e.capability }, to: dep });
1399
+ }
1400
+ }
1401
+ return { project: path.basename(p), path: p, entries };
1402
+ });
1403
+ // exact capability 중복
1404
+ const dupes = [...byCap.entries()].filter(([, occ]) => occ.length >= 2);
1405
+ // 1.9.18: --strict-elements: 같은 함수명이 다른 capability로 등록된 경우 잠재 중복
1406
+ const funcDupes = strictElements ? [...byFunc.entries()].filter(([, occ]) => {
1407
+ if (occ.length < 2) return false;
1408
+ // 정확 capability 중복이 아닌 경우만 (이미 dupes로 잡힌 건 제외)
1409
+ const caps = new Set(occ.map(o => o.entry.capability.toLowerCase()));
1410
+ return caps.size >= 2;
1411
+ }) : [];
1412
+
1413
+ if (has('--json')) {
1414
+ const duplicates = dupes.map(([cap, occ]) => ({ capability: cap, occurrences: occ.length, projects: occ.map(o => o.project) }));
1415
+ const fuzzyDuplicates = funcDupes.map(([fn, occ]) => ({
1416
+ functionName: fn,
1417
+ occurrences: occ.length,
1418
+ entries: occ.map(o => ({ project: o.project, capability: o.entry.capability, element: o.entry.element }))
1419
+ }));
1420
+ log(JSON.stringify({
1421
+ workspace: path.basename(root),
1422
+ projects,
1423
+ duplicates,
1424
+ fuzzyDuplicates,
1425
+ dependsEdges,
1426
+ totalCapabilities: byCap.size,
1427
+ strictElements
1428
+ }, null, 2));
1429
+ return;
1430
+ }
1431
+ log(`# Workspace Reuse Map — ${paths.length}개 프로젝트 / ${byCap.size}개 capability (1.9.18)`);
1432
+ log('');
1433
+ log(`## 프로젝트별 등록 수`);
1434
+ projects.forEach(p => log(` ${p.project}: ${p.entries.length}개`));
1435
+
1436
+ log('');
1437
+ log(`## 🔁 정확 중복 capability (이름 동일)`);
1438
+ if (!dupes.length) log(' (없음) — 모든 capability 이름이 단일 프로젝트에만 존재');
1439
+ else {
1440
+ for (const [cap, occ] of dupes) {
1441
+ log(` - "${occ[0].entry.capability}" — ${occ.length}개 프로젝트`);
1442
+ for (const o of occ) log(` · ${o.project}: ${o.entry.element} [${o.entry.method}]`);
1443
+ }
1444
+ log('');
1445
+ log(` 💡 권장: 가장 안정적인 1개 구현을 추출해 ${dupes.length}건 중복을 공통 모듈로 통합 검토`);
1446
+ }
1447
+
1448
+ // 1.9.18→1.9.19: --strict-elements 결과 (false-positive 줄이기 위해 same-file vs diff-file 구분)
1449
+ if (strictElements) {
1450
+ log('');
1451
+ log(`## 🔍 잠재 중복 (--strict-elements: 함수명 동일 / capability 이름 다름)`);
1452
+ if (!funcDupes.length) log(' (없음) — 동일 함수명을 다른 capability로 등록한 경우 없음');
1453
+ else {
1454
+ let exactMatches = 0; // 같은 파일 + 같은 함수 (진짜 중복 가능성 ↑)
1455
+ let intentionalSplits = 0; // 같은 함수 / 다른 파일 (의도 분리 가능성 ↑)
1456
+ for (const [fn, occ] of funcDupes) {
1457
+ const files = new Set(occ.map(o => o.entry.filePath));
1458
+ const sameFile = files.size === 1;
1459
+ const tag = sameFile ? '⚠ 진짜 중복 가능' : 'ℹ 의도 분리 가능';
1460
+ if (sameFile) exactMatches++; else intentionalSplits++;
1461
+ log(` - 함수 "${fn}()" — ${occ.length}건 ${tag}`);
1462
+ for (const o of occ) log(` · ${o.project}/${o.entry.capability}: ${o.entry.element}`);
1463
+ }
1464
+ log('');
1465
+ if (exactMatches > 0) log(` ⚠ 같은 파일 + 같은 함수: ${exactMatches}건 — 명명 통일 또는 실제 통합 검토`);
1466
+ if (intentionalSplits > 0) log(` ℹ 다른 파일 + 같은 함수: ${intentionalSplits}건 — 의도된 분리(예: 모듈 함수 vs CLI 명령)일 가능성. 보고용`);
1467
+ }
1468
+ }
1469
+
1470
+ // 1.9.18: depends-on 그래프
1471
+ if (dependsEdges.length) {
1472
+ log('');
1473
+ log(`## 🔗 의존 관계 (depends-on, ${dependsEdges.length}개 엣지)`);
1474
+ for (const e of dependsEdges) log(` - ${e.from.project}/${e.from.cap} ─→ ${e.to}`);
1475
+ log('');
1476
+ log(` 💡 의존 capability는 제거하지 말 것. depends-on 표기: \`notes\` 컬럼에 "depends-on: A, B"`);
1477
+ }
1478
+
1479
+ log('');
1480
+ const fuzzyCount = funcDupes.length;
1481
+ log(`## 📊 워크스페이스 총합: capability ${byCap.size}건 / 정확 중복 ${dupes.length}건${strictElements ? ` / 잠재 중복 ${fuzzyCount}건` : ''} / 의존 ${dependsEdges.length}건`);
1482
+ if (!strictElements) log(` 💡 \`--strict-elements\`로 함수명 기반 잠재 중복도 탐지 가능`);
1483
+ }
1484
+
1485
+ // 1.9.18: verify-claim — progress-tracker의 evidence 컬럼 자동 검증
1486
+ // "src/foo.js + 5개 테스트 (54/54 통과)" 같은 주장을 파싱해 실제 파일/카운트 확인
1487
+ function verifyClaimCmd(root, taskId) {
1488
+ root = absRoot(root);
1489
+ if (!taskId) return fail('verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008');
1490
+ const rows = readProgressRows(root);
1491
+ const row = rows.find(r => r.id === taskId);
1492
+ if (!row) return fail(`progress-tracker.md에 ${taskId} 없음.`);
1493
+
1494
+ const evidence = row.evidence || '';
1495
+ // 파일 경로 추출: src/x.js, bin/y.js, tests/z.js 등
1496
+ const filePatterns = evidence.match(/(?:src|bin|tests|public|lib)\/[\w./-]+\.(?:js|ts|html|css|json|md|webmanifest|xml)/g) || [];
1497
+ const files = Array.from(new Set(filePatterns));
1498
+ // 테스트 수 / pass 비율: "X/Y 통과" 또는 "X개 테스트"
1499
+ const passMatch = evidence.match(/(\d+)\s*\/\s*(\d+)\s*(통과|passed|pass)/);
1500
+ const testCountMatch = evidence.match(/(\d+)\s*개\s*테스트/);
1501
+ const declaredPass = passMatch ? { num: parseInt(passMatch[1], 10), denom: parseInt(passMatch[2], 10) } : null;
1502
+ const declaredTestCount = testCountMatch ? parseInt(testCountMatch[1], 10) : null;
1503
+
1504
+ // 실제 파일 존재 검사
1505
+ const fileChecks = files.map(f => ({ file: f, exists: exists(path.join(root, f)) }));
1506
+ // 테스트 카운트: tests/test.js의 check( 또는 it( 또는 test( 개수
1507
+ let actualTestCount = null;
1508
+ const candidateTestFiles = ['tests/test.js', 'test/test.js', 'tests/index.js'];
1509
+ for (const tf of candidateTestFiles) {
1510
+ const tp = path.join(root, tf);
1511
+ if (exists(tp)) {
1512
+ const t = read(tp);
1513
+ actualTestCount = (t.match(/\bcheck\s*\(/g) || t.match(/\b(it|test)\s*\(/g) || []).length;
1514
+ break;
1515
+ }
1516
+ }
1517
+
1518
+ // 1.9.19: --run-tests — npm test 자동 실행 + pass/fail 파싱
1519
+ let runResult = null;
1520
+ if (has('--run-tests')) {
1521
+ const pkgPath = path.join(root, 'package.json');
1522
+ if (!exists(pkgPath)) {
1523
+ runResult = { skipped: true, reason: 'package.json 없음' };
1524
+ } else {
1525
+ let pkg = null;
1526
+ try { pkg = JSON.parse(read(pkgPath)); } catch {}
1527
+ const hasTestScript = pkg && pkg.scripts && pkg.scripts.test;
1528
+ if (!hasTestScript) {
1529
+ runResult = { skipped: true, reason: 'scripts.test 없음' };
1530
+ } else {
1531
+ const r = cp.spawnSync('npm test', [], { cwd: root, encoding: 'utf8', shell: true, timeout: 5 * 60 * 1000 });
1532
+ const out = (r.stdout || '') + (r.stderr || '');
1533
+ // "54/54 passed" 또는 "54/54 통과" 등을 파싱
1534
+ const m = out.match(/(\d+)\s*\/\s*(\d+)\s*(?:passed|통과|pass)/);
1535
+ runResult = {
1536
+ skipped: false,
1537
+ exitCode: r.status,
1538
+ parsed: m ? { num: parseInt(m[1], 10), denom: parseInt(m[2], 10) } : null,
1539
+ allPassed: r.status === 0 && (!m || (m && m[1] === m[2]))
1540
+ };
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ if (has('--json')) {
1546
+ const out = {
1547
+ project: path.basename(root),
1548
+ taskId, row,
1549
+ declared: { files: files.length, pass: declaredPass, testCount: declaredTestCount },
1550
+ actual: { fileChecks, testCount: actualTestCount },
1551
+ verdict: {
1552
+ filesAllExist: fileChecks.every(c => c.exists),
1553
+ testCountMatch: declaredTestCount == null || actualTestCount == null || actualTestCount >= declaredTestCount
1554
+ }
1555
+ };
1556
+ if (runResult) {
1557
+ out.run = runResult;
1558
+ out.verdict.runTests = !!runResult.allPassed;
1559
+ // declared pass와 실제 비교
1560
+ if (declaredPass && runResult.parsed) {
1561
+ out.verdict.declaredPassMatches = (runResult.parsed.num === declaredPass.num && runResult.parsed.denom === declaredPass.denom);
1562
+ }
1563
+ }
1564
+ log(JSON.stringify(out, null, 2));
1565
+ if (runResult && !runResult.skipped && !runResult.allPassed) return process.exit(1);
1566
+ if (!out.verdict.filesAllExist || !out.verdict.testCountMatch) return process.exit(1);
1567
+ return;
1568
+ }
1569
+
1570
+ log(`# verify-claim ${taskId} (${path.basename(root)})`);
1571
+ log(`Request: ${row.request}`);
1572
+ log(`Status: ${row.status} · Updated: ${row.updated}`);
1573
+ log(`Evidence: ${evidence.slice(0, 200)}${evidence.length > 200 ? '…' : ''}`);
1574
+ log('');
1575
+ log(`## 📂 파일 검증 (${files.length}건 주장)`);
1576
+ if (!files.length) log(' (evidence에서 파일 경로를 추출하지 못함)');
1577
+ else {
1578
+ for (const c of fileChecks) log(` ${c.exists ? '✓' : '✗'} ${c.file}${c.exists ? '' : ' ← 누락'}`);
1579
+ }
1580
+ log('');
1581
+ log(`## 🧪 테스트 카운트`);
1582
+ if (declaredPass) log(` 주장 (pass): ${declaredPass.num}/${declaredPass.denom}`);
1583
+ if (declaredTestCount) log(` 주장 (개수): ${declaredTestCount}개`);
1584
+ if (actualTestCount != null) log(` 실측: tests/test.js에 ${actualTestCount}개 check/test 호출`);
1585
+ else log(` 실측: 테스트 파일 못 찾음 (tests/test.js 등)`);
1586
+
1587
+ // 1.9.19: --run-tests 결과
1588
+ let runTestsOk = true;
1589
+ let declaredPassMatchesActual = true;
1590
+ if (runResult) {
1591
+ log('');
1592
+ log(`## 🚦 npm test 실행 (--run-tests)`);
1593
+ if (runResult.skipped) {
1594
+ log(` ⚠ skipped: ${runResult.reason}`);
1595
+ } else {
1596
+ log(` exit: ${runResult.exitCode}`);
1597
+ if (runResult.parsed) log(` 실행 결과: ${runResult.parsed.num}/${runResult.parsed.denom} ${runResult.parsed.num === runResult.parsed.denom ? 'passed' : 'partial'}`);
1598
+ else log(` (pass/fail 비율을 stdout에서 파싱 못함)`);
1599
+ runTestsOk = runResult.allPassed;
1600
+ if (declaredPass && runResult.parsed) {
1601
+ declaredPassMatchesActual = (runResult.parsed.num === declaredPass.num && runResult.parsed.denom === declaredPass.denom);
1602
+ log(` 주장 vs 실행: ${declaredPassMatchesActual ? '✓ 일치' : `⚠ 불일치 (주장 ${declaredPass.num}/${declaredPass.denom} ≠ 실행 ${runResult.parsed.num}/${runResult.parsed.denom})`}`);
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ log('');
1608
+ const allFilesOk = fileChecks.every(c => c.exists);
1609
+ const testOk = declaredTestCount == null || actualTestCount == null || actualTestCount >= declaredTestCount;
1610
+ log(`## 종합`);
1611
+ log(` - 파일 모두 존재: ${allFilesOk ? '✓ pass' : '✗ FAIL (일부 누락)'}`);
1612
+ log(` - 테스트 카운트: ${testOk ? '✓ pass (실측 ≥ 주장)' : '⚠ 주장보다 적음'}`);
1613
+ if (runResult && !runResult.skipped) {
1614
+ log(` - npm test 실행: ${runTestsOk ? '✓ all passed' : '✗ FAIL'}`);
1615
+ if (declaredPass) log(` - 주장과 실행 결과 일치: ${declaredPassMatchesActual ? '✓ pass' : '⚠ 다름'}`);
1616
+ }
1617
+ const overallFail = !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk);
1618
+ if (overallFail) {
1619
+ log('');
1620
+ log(` ⚠ evidence 주장과 실제가 일치하지 않음 — task 상태 재검토 권장`);
1621
+ return process.exit(1);
1622
+ }
1623
+ log('');
1624
+ log(` ✓ evidence 주장이 실제 파일·테스트${runResult && !runResult.skipped ? '·실행 결과' : ''}와 일치`);
1625
+ }
1626
+
1203
1627
  function sessionClose(root) {
1204
1628
  root = absRoot(root);
1205
1629
  const rows = readProgressRows(root);
@@ -3195,7 +3619,7 @@ function viewworkInstall(root) {
3195
3619
  }
3196
3620
 
3197
3621
  function help() {
3198
- log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
3622
+ log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--json] # 1.9.17/18 워크스페이스\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18/19 evidence 자동 검증 (+npm test 동적)\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
3199
3623
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
3200
3624
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
3201
3625
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -3228,7 +3652,9 @@ async function main() {
3228
3652
  if (cmd === 'encoding' && args[1] === 'check') return encodingCheck(args[2] || process.cwd());
3229
3653
  if (cmd === 'lazy' && args[1] === 'detect') return lazyDetect(args[2] || process.cwd());
3230
3654
  if (cmd === 'memory' && args[1] === 'search') return memorySearch(arg('--path', process.cwd()), args.slice(2).join(' '));
3231
- if (cmd === 'handoff') return handoff(args[1] || process.cwd());
3655
+ if (cmd === 'handoff') return handoffCmd(args[1] || process.cwd());
3656
+ if (cmd === 'reuse-map') return reuseMapCmd(args[1] || process.cwd());
3657
+ if (cmd === 'verify-claim') return verifyClaimCmd(arg('--path', process.cwd()), args[1]);
3232
3658
  if (cmd === 'session' && args[1] === 'close') { const r = sessionClose(args[2] || process.cwd()); viewworkEmit(args[2] || process.cwd(), { action: 'task', tool: 'session-close', note: 'session close' }); return r; }
3233
3659
  if (cmd === 'viewwork' && args[1] === 'install') return viewworkInstall(args[2] || process.cwd());
3234
3660
  if (cmd === 'viewwork' && args[1] === 'emit') return viewworkEmit(args[2] || process.cwd(), { action: arg('--action','task'), note: arg('--note',''), agent: arg('--agent','leerness'), tool: arg('--tool','leerness-cli') });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.16",
3
+ "version": "1.9.19",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -312,6 +312,237 @@ total++;
312
312
  if (!ok) { failed++; console.log(r.stdout.slice(-500)); }
313
313
  }
314
314
 
315
+ // 1.9.17 회귀: handoff --all-apps / reuse-map --all-apps (워크스페이스 오케스트레이션)
316
+ total++;
317
+ {
318
+ // handoff --include
319
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ha-'));
320
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-hb-'));
321
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
322
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
323
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', '--include', `${tmpA},${tmpB}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
324
+ const ok = r.status === 0 && /Workspace Handoff — 2개 프로젝트 \(1\.9\.1[78]\)/.test(r.stdout) && /워크스페이스 총합/.test(r.stdout) && /오케스트레이션 권장/.test(r.stdout);
325
+ console.log(ok ? '✓ B(1.9.17) handoff --include 통합 워크스페이스 뷰' : '✗ handoff --include 실패');
326
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
327
+ }
328
+
329
+ total++;
330
+ {
331
+ // handoff --include --json
332
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-haj-'));
333
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-hbj-'));
334
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
335
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
336
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', '--include', `${tmpA},${tmpB}`, '--json'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
337
+ let parsed = null;
338
+ try { parsed = JSON.parse(r.stdout); } catch {}
339
+ const ok = r.status === 0 && parsed && Array.isArray(parsed.projects) && parsed.projects.length === 2 && parsed.totals;
340
+ console.log(ok ? '✓ B(1.9.17) handoff --include --json' : '✗ handoff --json 실패');
341
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
342
+ }
343
+
344
+ total++;
345
+ {
346
+ // reuse-map 단일 + 워크스페이스 모드 + 중복 감지
347
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rma-'));
348
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rmb-'));
349
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
350
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
351
+ // 양쪽에 같은 capability "Cache" 추가 → 중복 감지 기대
352
+ const rowA = '| Cache | src/cache.js | util | LRU |\n';
353
+ const rowB = '| Cache | src/foo.js | util | Memoize |\n';
354
+ fs.appendFileSync(path.join(tmpA, '.harness/reuse-map.md'), rowA);
355
+ fs.appendFileSync(path.join(tmpB, '.harness/reuse-map.md'), rowB);
356
+ // 단일
357
+ const rs = cp.spawnSync(process.execPath, [CLI, 'reuse-map', tmpA], { encoding: 'utf8', timeout: 15000 });
358
+ const okSingle = rs.status === 0 && /Reuse Map/.test(rs.stdout) && /Cache/.test(rs.stdout);
359
+ // 워크스페이스
360
+ const rw = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', `${tmpA},${tmpB}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
361
+ const okMulti = rw.status === 0 && /Workspace Reuse Map — 2개 프로젝트/.test(rw.stdout) && /중복 capability/.test(rw.stdout) && /"Cache"/.test(rw.stdout);
362
+ // JSON
363
+ const rj = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', `${tmpA},${tmpB}`, '--json'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
364
+ let parsed = null;
365
+ try { parsed = JSON.parse(rj.stdout); } catch {}
366
+ const okJson = rj.status === 0 && parsed && Array.isArray(parsed.duplicates) && parsed.duplicates.length >= 1;
367
+ const ok = okSingle && okMulti && okJson;
368
+ console.log(ok ? '✓ B(1.9.17) reuse-map 단일/워크스페이스/JSON + 중복 감지' : `✗ reuse-map 실패 (단일=${okSingle} 멀티=${okMulti} JSON=${okJson})`);
369
+ if (!ok) { failed++; console.log(rw.stdout.slice(0, 400)); }
370
+ }
371
+
372
+ // 1.9.18 회귀: handoff --since / reuse-map --strict-elements + depends-on / verify-claim
373
+ total++;
374
+ {
375
+ // handoff --since: 최근 변경 강조
376
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sincea-'));
377
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sinceb-'));
378
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
379
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
380
+ // 오늘 날짜로 T-row 추가
381
+ const today = new Date().toISOString().slice(0,10);
382
+ fs.appendFileSync(path.join(tmpA, '.harness/progress-tracker.md'), `| T-9999 | done | 신규 기능 | src/x.js | M-NEW | ${today} |\n`);
383
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', '--include', `${tmpA},${tmpB}`, '--since', '1d'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
384
+ const ok = r.status === 0 && /1\.9\.18/.test(r.stdout) && /Filter: since 1d/.test(r.stdout) && /🆕/.test(r.stdout) && /최근 변경/.test(r.stdout);
385
+ console.log(ok ? '✓ B(1.9.18) handoff --since: 최근 변경 강조' : '✗ handoff --since 실패');
386
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
387
+ }
388
+
389
+ total++;
390
+ {
391
+ // handoff --since 형식 오류 → fail
392
+ const tmpE = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sincee-'));
393
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpE, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
394
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', '--include', tmpE, '--since', 'banana'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
395
+ const ok = r.status !== 0 && /형식 오류/.test(r.stdout + r.stderr);
396
+ console.log(ok ? '✓ B(1.9.18) handoff --since 형식 오류 → exit≠0' : '✗ handoff --since 오류 검증 실패');
397
+ if (!ok) failed++;
398
+ }
399
+
400
+ total++;
401
+ {
402
+ // reuse-map --strict-elements: 같은 함수명 다른 capability 감지
403
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-strict-a-'));
404
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-strict-b-'));
405
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
406
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
407
+ // 같은 함수 escapeHtml을 다른 capability 이름으로 등록
408
+ fs.appendFileSync(path.join(tmpA, '.harness/reuse-map.md'), '| HtmlEscape | src/util.js (escapeHtml) | util | XSS 방지 |\n');
409
+ fs.appendFileSync(path.join(tmpB, '.harness/reuse-map.md'), '| EscapeHtml | src/build.js (escapeHtml) | util | 마크업 이스케이프 |\n');
410
+ // 기본 모드 → 정확 중복 0
411
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', `${tmpA},${tmpB}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
412
+ const okDefault = r1.status === 0 && /정확 중복 capability/.test(r1.stdout) && /\(없음\)/.test(r1.stdout);
413
+ // --strict-elements → 잠재 중복 1건
414
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', `${tmpA},${tmpB}`, '--strict-elements'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
415
+ const okStrict = r2.status === 0 && /잠재 중복/.test(r2.stdout) && /escapeHtml/.test(r2.stdout) && /HtmlEscape/.test(r2.stdout) && /EscapeHtml/.test(r2.stdout);
416
+ // JSON
417
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', `${tmpA},${tmpB}`, '--strict-elements', '--json'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
418
+ let parsed = null;
419
+ try { parsed = JSON.parse(r3.stdout); } catch {}
420
+ const okJson = r3.status === 0 && parsed && Array.isArray(parsed.fuzzyDuplicates) && parsed.fuzzyDuplicates.length === 1 && parsed.fuzzyDuplicates[0].functionName === 'escapehtml';
421
+ const ok = okDefault && okStrict && okJson;
422
+ console.log(ok ? '✓ B(1.9.18) reuse-map --strict-elements 잠재 중복 감지' : `✗ strict-elements 실패 (default=${okDefault} strict=${okStrict} json=${okJson})`);
423
+ if (!ok) { failed++; console.log(r2.stdout.slice(0, 500)); }
424
+ }
425
+
426
+ total++;
427
+ {
428
+ // reuse-map depends-on: notes 컬럼에서 의존 추출
429
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-deps-'));
430
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
431
+ fs.appendFileSync(path.join(tmpA, '.harness/reuse-map.md'),
432
+ '| EscapeHtml | src/build.js (escapeHtml) | util | XSS 방지 |\n' +
433
+ '| RssFeed | src/build.js (buildFeed) | util | RSS 2.0 (depends-on: EscapeHtml) |\n');
434
+ const r = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', tmpA], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
435
+ const ok = r.status === 0 && /의존 관계 \(depends-on, 1개 엣지\)/.test(r.stdout) && /RssFeed.*─→.*EscapeHtml/.test(r.stdout);
436
+ console.log(ok ? '✓ B(1.9.18) reuse-map depends-on 엣지 추출' : '✗ depends-on 실패');
437
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
438
+ }
439
+
440
+ total++;
441
+ {
442
+ // verify-claim: 파일 존재 + 테스트 카운트 검증
443
+ const tmpV = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-vc-'));
444
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpV, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
445
+ // 실제 src 파일 + 테스트 파일 생성 (5개 check)
446
+ fs.mkdirSync(path.join(tmpV, 'src'), { recursive: true });
447
+ fs.mkdirSync(path.join(tmpV, 'tests'), { recursive: true });
448
+ fs.writeFileSync(path.join(tmpV, 'src/myMod.js'), 'module.exports = {};\n');
449
+ fs.writeFileSync(path.join(tmpV, 'tests/test.js'), 'check(1); check(2); check(3); check(4); check(5);\n');
450
+ // T-row를 evidence와 함께 추가
451
+ fs.appendFileSync(path.join(tmpV, '.harness/progress-tracker.md'),
452
+ '| T-0099 | done | 신모듈 | src/myMod.js + tests/test.js (5/5 통과) | next | 2026-05-14 |\n');
453
+ // 정상: 파일 존재 + 테스트 5개
454
+ const r = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0099', '--path', tmpV], { encoding: 'utf8', timeout: 15000 });
455
+ const okPass = r.status === 0 && /✓ src\/myMod\.js/.test(r.stdout) && /✓ tests\/test\.js/.test(r.stdout) && /pass \(실측 ≥ 주장\)/.test(r.stdout);
456
+ // 파일 없는 케이스 → exit ≠ 0
457
+ fs.unlinkSync(path.join(tmpV, 'src/myMod.js'));
458
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0099', '--path', tmpV], { encoding: 'utf8', timeout: 15000 });
459
+ const okFail = r2.status !== 0 && /✗ src\/myMod\.js/.test(r2.stdout) && /FAIL/.test(r2.stdout);
460
+ // JSON
461
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0099', '--path', tmpV, '--json'], { encoding: 'utf8', timeout: 15000 });
462
+ let parsed = null;
463
+ try { parsed = JSON.parse(r3.stdout); } catch {}
464
+ const okJson = parsed && parsed.taskId === 'T-0099' && parsed.verdict && parsed.verdict.filesAllExist === false;
465
+ const ok = okPass && okFail && okJson;
466
+ console.log(ok ? '✓ B(1.9.18) verify-claim 파일/테스트 검증 + exit code + JSON' : `✗ verify-claim 실패 (pass=${okPass} fail=${okFail} json=${okJson})`);
467
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
468
+ }
469
+
470
+ // 1.9.19 회귀: verify-claim --run-tests + --strict-elements same-file 구분
471
+ total++;
472
+ {
473
+ // verify-claim --run-tests: npm test 자동 실행 + 주장 vs 실행 결과 대조
474
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rt-'));
475
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
476
+ // 최소 npm 프로젝트 + 단순 tests/test.js (5/5 pass)
477
+ fs.writeFileSync(path.join(tmpR, 'package.json'), JSON.stringify({
478
+ name: 'rt-fixture', version: '0.0.1', scripts: { test: 'node tests/test.js' }
479
+ }));
480
+ fs.mkdirSync(path.join(tmpR, 'src'), { recursive: true });
481
+ fs.mkdirSync(path.join(tmpR, 'tests'), { recursive: true });
482
+ fs.writeFileSync(path.join(tmpR, 'src/mod.js'), 'module.exports={};\n');
483
+ // 5 check 호출 + "5/5 passed" 직접 출력 (간단한 fixture)
484
+ fs.writeFileSync(path.join(tmpR, 'tests/test.js'),
485
+ "let p=0;function check(c){if(c)p++;}check(1);check(1);check(1);check(1);check(1);console.log(p+'/5 passed');if(p!==5)process.exit(1);\n");
486
+ fs.appendFileSync(path.join(tmpR, '.harness/progress-tracker.md'),
487
+ '| T-0050 | done | rt 작업 | src/mod.js + tests/test.js (5/5 통과) | next | 2026-05-14 |\n');
488
+ const r = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0050', '--path', tmpR, '--run-tests'], { encoding: 'utf8', timeout: 60000 });
489
+ const ok = r.status === 0
490
+ && /npm test 실행 \(--run-tests\)/.test(r.stdout)
491
+ && /실행 결과: 5\/5 passed/.test(r.stdout)
492
+ && /주장 vs 실행: ✓ 일치/.test(r.stdout)
493
+ && /npm test 실행: ✓ all passed/.test(r.stdout);
494
+ console.log(ok ? '✓ B(1.9.19) verify-claim --run-tests: npm test 실행 + 주장 일치' : '✗ --run-tests 실패');
495
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
496
+ }
497
+
498
+ total++;
499
+ {
500
+ // --run-tests 실패 케이스: 5/5 주장 vs 실제 3/5 실행 → 불일치 + exit 1
501
+ const tmpF = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rtf-'));
502
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpF, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
503
+ fs.writeFileSync(path.join(tmpF, 'package.json'), JSON.stringify({ name: 'rtf', version: '0.0.1', scripts: { test: 'node tests/test.js' } }));
504
+ fs.mkdirSync(path.join(tmpF, 'src'), { recursive: true });
505
+ fs.mkdirSync(path.join(tmpF, 'tests'), { recursive: true });
506
+ fs.writeFileSync(path.join(tmpF, 'src/mod.js'), 'module.exports={};\n');
507
+ fs.writeFileSync(path.join(tmpF, 'tests/test.js'),
508
+ "console.log('3/5 passed'); process.exit(1);\n");
509
+ fs.appendFileSync(path.join(tmpF, '.harness/progress-tracker.md'),
510
+ '| T-0051 | done | 거짓 | src/mod.js + tests/test.js (5/5 통과) | next | 2026-05-14 |\n');
511
+ const r = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0051', '--path', tmpF, '--run-tests'], { encoding: 'utf8', timeout: 60000 });
512
+ const ok = r.status !== 0
513
+ && /불일치/.test(r.stdout)
514
+ && /npm test 실행: ✗ FAIL/.test(r.stdout);
515
+ console.log(ok ? '✓ B(1.9.19) verify-claim --run-tests: 주장/실행 불일치 → exit≠0' : '✗ --run-tests 불일치 검증 실패');
516
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
517
+ }
518
+
519
+ total++;
520
+ {
521
+ // --strict-elements: same-file ⚠ vs diff-file ℹ 구분
522
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-strict2-a-'));
523
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-strict2-b-'));
524
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
525
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
526
+ // 같은 파일 + 같은 함수 (다른 capability 이름) — 진짜 중복 가능
527
+ fs.appendFileSync(path.join(tmpA, '.harness/reuse-map.md'),
528
+ '| FormatX | src/util.js (format) | util | A |\n');
529
+ fs.appendFileSync(path.join(tmpB, '.harness/reuse-map.md'),
530
+ '| FormatY | src/util.js (format) | util | B |\n');
531
+ // 다른 파일 + 같은 함수 — 의도 분리 가능
532
+ fs.appendFileSync(path.join(tmpA, '.harness/reuse-map.md'),
533
+ '| Stats1 | src/memo.js (stats) | util | A |\n');
534
+ fs.appendFileSync(path.join(tmpB, '.harness/reuse-map.md'),
535
+ '| Stats2 | bin/cli.js (stats) | command | B |\n');
536
+ const r = cp.spawnSync(process.execPath, [CLI, 'reuse-map', '--include', `${tmpA},${tmpB}`, '--strict-elements'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
537
+ const ok = r.status === 0
538
+ && /⚠ 진짜 중복 가능/.test(r.stdout)
539
+ && /ℹ 의도 분리 가능/.test(r.stdout)
540
+ && /같은 파일 \+ 같은 함수: 1건/.test(r.stdout)
541
+ && /다른 파일 \+ 같은 함수: 1건/.test(r.stdout);
542
+ console.log(ok ? '✓ B(1.9.19) --strict-elements: same-file ⚠ vs diff-file ℹ 구분' : '✗ strict-elements 분류 실패');
543
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
544
+ }
545
+
315
546
  // 1.9.15 회귀: brainstorm 라인번호 / --all-apps / --include
316
547
  total++;
317
548
  {