leerness 1.9.13 → 1.9.18

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/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.13';
9
+ const VERSION = '1.9.18';
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']);
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];
@@ -218,7 +218,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = []) {
218
218
  '.harness/protected-files.md': fm('protected-files', ['파일 삭제/정리/마이그레이션 전'], ['보호 대상 변경'], `# Protected Files\n\nAI agents must not delete or reset these files without explicit user approval.\n\n- .harness/\n- .harness/skills/\n- .harness/library/\n- AGENTS.md\n- CLAUDE.md\n- .cursor/rules/leerness.mdc\n- .github/copilot-instructions.md\n- .claude/commands/\n- .claude/skills/\n- README.md Leerness managed section\n\nUse merge, archive, or deprecated markers instead of deletion.\n`),
219
219
  '.harness/architecture.md': fm('architecture', ['기능 구현','리팩토링','마이그레이션'], ['구조 변경'], `# Architecture\n\n## Overview\n- 실제 구조를 기록하세요.\n\n## Data Flow\n-\n\n## External Dependencies\n-\n`),
220
220
  '.harness/context-map.md': fm('context-map', ['관련 파일 탐색','기능 구현 전'], ['파일 구조 변경'], `# Context Map\n\n| Area | Files | Notes |\n|---|---|---|\n| App | src/** | 실제 경로로 업데이트 |\n| Tests | tests/** | 검증 경로 |\n`),
221
- '.harness/decisions.md': fm('decisions', ['설계 결정 확인'], ['중요 결정 발생'], `# Decisions\n\n## Template\n### ${today()} — Decision\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n`),
221
+ '.harness/decisions.md': fm('decisions', ['설계 결정 확인'], ['중요 결정 발생'], `# Decisions\n\n## Template (예시 — 실제 결정은 아래 코드블록 밖에 추가)\n\n\`\`\`md\n### ${today()} — Decision 제목\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n\`\`\`\n`),
222
222
  '.harness/task-log.md': fm('task-log', ['작업 이력 확인'], ['모든 의미 있는 작업 후'], `# Task Log\n\n## ${today()}\n- Leerness v${VERSION} initialized.\n`),
223
223
  '.harness/guardrails.md': fm('guardrails', ['모든 작업 전','보안/권한/리팩토링 전'], ['금지 규칙 변경'], `# Guardrails\n\n- 토큰/키/비밀번호를 저장하지 않습니다. 환경변수 이름만 기록합니다.\n- 요청 없는 대규모 리팩토링을 하지 않습니다 (5개 이상 파일 변경 시 사용자 사전 승인).\n- API/DB/환경변수 변경은 영향 범위를 task-log에 기록합니다.\n- Leerness 보호 파일/관리 섹션을 삭제하지 않습니다.\n- 한글 인코딩은 BOM 없는 UTF-8을 유지합니다.\n- destructive Git 작업(\`git reset --hard\`, \`git push --force\` 등)은 사용자 명시 승인 후에만 수행합니다.\n`),
224
224
  '.harness/design-system.md': fm('design-system', ['UI 변경','컴포넌트 추가','designguide 병합'], ['디자인 기준 변경','재사용 패턴 발견'], `# Design System\n\n## Canonical File\n이 파일은 designguide.md, design-guide.md와 같은 디자인 가이드의 기준 파일입니다.\n\n## Tokens\n| Token | Value | Notes |\n|---|---|---|\n| color.primary | (실제 값으로 업데이트) | |\n| color.surface | | |\n| spacing.unit | | |\n| typography.body | | |\n\n## Reusable Patterns\n| Pattern | Where | Reuse Rule |\n|---|---|---|\n`),
@@ -1200,6 +1200,359 @@ 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: --strict-elements 결과
1449
+ if (strictElements) {
1450
+ log('');
1451
+ log(`## 🔍 잠재 중복 (--strict-elements: 함수명 동일 / capability 이름 다름)`);
1452
+ if (!funcDupes.length) log(' (없음) — 동일 함수명을 다른 capability로 등록한 경우 없음');
1453
+ else {
1454
+ for (const [fn, occ] of funcDupes) {
1455
+ log(` - 함수 "${fn}()" — ${occ.length}건 (이름 다름)`);
1456
+ for (const o of occ) log(` · ${o.project}/${o.entry.capability}: ${o.entry.element}`);
1457
+ }
1458
+ log('');
1459
+ log(` 💡 같은 함수를 다른 capability 이름으로 부르고 있을 가능성. 명명 통일 검토.`);
1460
+ }
1461
+ }
1462
+
1463
+ // 1.9.18: depends-on 그래프
1464
+ if (dependsEdges.length) {
1465
+ log('');
1466
+ log(`## 🔗 의존 관계 (depends-on, ${dependsEdges.length}개 엣지)`);
1467
+ for (const e of dependsEdges) log(` - ${e.from.project}/${e.from.cap} ─→ ${e.to}`);
1468
+ log('');
1469
+ log(` 💡 의존 capability는 제거하지 말 것. depends-on 표기: \`notes\` 컬럼에 "depends-on: A, B"`);
1470
+ }
1471
+
1472
+ log('');
1473
+ const fuzzyCount = funcDupes.length;
1474
+ log(`## 📊 워크스페이스 총합: capability ${byCap.size}건 / 정확 중복 ${dupes.length}건${strictElements ? ` / 잠재 중복 ${fuzzyCount}건` : ''} / 의존 ${dependsEdges.length}건`);
1475
+ if (!strictElements) log(` 💡 \`--strict-elements\`로 함수명 기반 잠재 중복도 탐지 가능`);
1476
+ }
1477
+
1478
+ // 1.9.18: verify-claim — progress-tracker의 evidence 컬럼 자동 검증
1479
+ // "src/foo.js + 5개 테스트 (54/54 통과)" 같은 주장을 파싱해 실제 파일/카운트 확인
1480
+ function verifyClaimCmd(root, taskId) {
1481
+ root = absRoot(root);
1482
+ if (!taskId) return fail('verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008');
1483
+ const rows = readProgressRows(root);
1484
+ const row = rows.find(r => r.id === taskId);
1485
+ if (!row) return fail(`progress-tracker.md에 ${taskId} 없음.`);
1486
+
1487
+ const evidence = row.evidence || '';
1488
+ // 파일 경로 추출: src/x.js, bin/y.js, tests/z.js 등
1489
+ const filePatterns = evidence.match(/(?:src|bin|tests|public|lib)\/[\w./-]+\.(?:js|ts|html|css|json|md|webmanifest|xml)/g) || [];
1490
+ const files = Array.from(new Set(filePatterns));
1491
+ // 테스트 수 / pass 비율: "X/Y 통과" 또는 "X개 테스트"
1492
+ const passMatch = evidence.match(/(\d+)\s*\/\s*(\d+)\s*(통과|passed|pass)/);
1493
+ const testCountMatch = evidence.match(/(\d+)\s*개\s*테스트/);
1494
+ const declaredPass = passMatch ? { num: parseInt(passMatch[1], 10), denom: parseInt(passMatch[2], 10) } : null;
1495
+ const declaredTestCount = testCountMatch ? parseInt(testCountMatch[1], 10) : null;
1496
+
1497
+ // 실제 파일 존재 검사
1498
+ const fileChecks = files.map(f => ({ file: f, exists: exists(path.join(root, f)) }));
1499
+ // 테스트 카운트: tests/test.js의 check( 또는 it( 또는 test( 개수
1500
+ let actualTestCount = null;
1501
+ const candidateTestFiles = ['tests/test.js', 'test/test.js', 'tests/index.js'];
1502
+ for (const tf of candidateTestFiles) {
1503
+ const tp = path.join(root, tf);
1504
+ if (exists(tp)) {
1505
+ const t = read(tp);
1506
+ actualTestCount = (t.match(/\bcheck\s*\(/g) || t.match(/\b(it|test)\s*\(/g) || []).length;
1507
+ break;
1508
+ }
1509
+ }
1510
+
1511
+ if (has('--json')) {
1512
+ log(JSON.stringify({
1513
+ project: path.basename(root),
1514
+ taskId, row,
1515
+ declared: { files: files.length, pass: declaredPass, testCount: declaredTestCount },
1516
+ actual: { fileChecks, testCount: actualTestCount },
1517
+ verdict: {
1518
+ filesAllExist: fileChecks.every(c => c.exists),
1519
+ testCountMatch: declaredTestCount == null || actualTestCount == null || actualTestCount >= declaredTestCount
1520
+ }
1521
+ }, null, 2));
1522
+ return;
1523
+ }
1524
+
1525
+ log(`# verify-claim ${taskId} (${path.basename(root)})`);
1526
+ log(`Request: ${row.request}`);
1527
+ log(`Status: ${row.status} · Updated: ${row.updated}`);
1528
+ log(`Evidence: ${evidence.slice(0, 200)}${evidence.length > 200 ? '…' : ''}`);
1529
+ log('');
1530
+ log(`## 📂 파일 검증 (${files.length}건 주장)`);
1531
+ if (!files.length) log(' (evidence에서 파일 경로를 추출하지 못함)');
1532
+ else {
1533
+ for (const c of fileChecks) log(` ${c.exists ? '✓' : '✗'} ${c.file}${c.exists ? '' : ' ← 누락'}`);
1534
+ }
1535
+ log('');
1536
+ log(`## 🧪 테스트 카운트`);
1537
+ if (declaredPass) log(` 주장 (pass): ${declaredPass.num}/${declaredPass.denom}`);
1538
+ if (declaredTestCount) log(` 주장 (개수): ${declaredTestCount}개`);
1539
+ if (actualTestCount != null) log(` 실측: tests/test.js에 ${actualTestCount}개 check/test 호출`);
1540
+ else log(` 실측: 테스트 파일 못 찾음 (tests/test.js 등)`);
1541
+ log('');
1542
+ const allFilesOk = fileChecks.every(c => c.exists);
1543
+ const testOk = declaredTestCount == null || actualTestCount == null || actualTestCount >= declaredTestCount;
1544
+ log(`## 종합`);
1545
+ log(` - 파일 모두 존재: ${allFilesOk ? '✓ pass' : '✗ FAIL (일부 누락)'}`);
1546
+ log(` - 테스트 카운트: ${testOk ? '✓ pass (실측 ≥ 주장)' : '⚠ 주장보다 적음'}`);
1547
+ if (!allFilesOk || !testOk) {
1548
+ log('');
1549
+ log(` ⚠ evidence 주장과 실제가 일치하지 않음 — task 상태 재검토 권장`);
1550
+ return process.exit(1);
1551
+ }
1552
+ log('');
1553
+ log(` ✓ evidence 주장이 실제 파일·테스트와 일치`);
1554
+ }
1555
+
1203
1556
  function sessionClose(root) {
1204
1557
  root = absRoot(root);
1205
1558
  const rows = readProgressRows(root);
@@ -1297,6 +1650,22 @@ function sessionClose(root) {
1297
1650
  const left = 5 - (sc.count % 5);
1298
1651
  log(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`);
1299
1652
  }
1653
+ // 1.9.16: 워크스페이스 안내 (다른 leerness 프로젝트가 있으면)
1654
+ try {
1655
+ const wsCands = [path.resolve(root, '_apps'), path.resolve(root, '..', '_apps')];
1656
+ let wsCount = 0;
1657
+ for (const base of wsCands) {
1658
+ if (!exists(base)) continue;
1659
+ try { if (!fs.statSync(base).isDirectory()) continue; } catch { continue; }
1660
+ for (const e of fs.readdirSync(base)) {
1661
+ try {
1662
+ const p = path.join(base, e);
1663
+ if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness')) && p !== root) wsCount++;
1664
+ } catch {}
1665
+ }
1666
+ }
1667
+ if (wsCount > 0) log(` 🌐 워크스페이스에 ${wsCount}개 다른 leerness 프로젝트 — \`leerness retro --all-apps\`로 통합 회고`);
1668
+ } catch {}
1300
1669
  } catch (e) {
1301
1670
  warn('retro 요약 실패: ' + (e && e.message ? e.message : e));
1302
1671
  }
@@ -1356,6 +1725,15 @@ function readSessionCounter(root) {
1356
1725
  }
1357
1726
  function writeSessionCounter(root, c) { writeUtf8(sessionCounterPath(root), JSON.stringify(c, null, 2) + '\n'); }
1358
1727
 
1728
+ // 1.9.14 A/D: 결정 블록 추출 — 코드 블록 안의 ### + Template 제외
1729
+ function _extractDecisionBlocks(text) {
1730
+ // 줄 시작의 ```부터 줄 시작의 ```까지를 코드블록으로 인식 (인라인 백틱 무시)
1731
+ const cleaned = String(text || '').replace(/^```[^\n]*\n[\s\S]*?\n```\s*$/gm, '');
1732
+ return cleaned.split(/\n(?=### )/).filter(b =>
1733
+ b.startsWith('### ') && !/^### (Template|템플릿)\b/.test(b.trim())
1734
+ );
1735
+ }
1736
+
1359
1737
  function _retroAggregate(root) {
1360
1738
  root = absRoot(root);
1361
1739
  const rows = readProgressRows(root);
@@ -1369,8 +1747,8 @@ function _retroAggregate(root) {
1369
1747
  for (const s of STATUSES) statusCounts[s] = 0;
1370
1748
  for (const r of rows) if (statusCounts[r.status] != null) statusCounts[r.status]++;
1371
1749
 
1372
- // 2) 결정 블록 수
1373
- const decisionBlocks = decisions.split(/\n(?=### )/).filter(b => b.startsWith('### '));
1750
+ // 2) 결정 블록 수 (1.9.14: 코드블록/Template 제외)
1751
+ const decisionBlocks = _extractDecisionBlocks(decisions);
1374
1752
  // recent decisions (날짜로 정렬 시 가장 최근)
1375
1753
  const recentDecisions = decisionBlocks.slice(-5).map(b => {
1376
1754
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
@@ -1412,9 +1790,10 @@ function _retroAggregate(root) {
1412
1790
  const activeRules = rules.filter(r => r.status === 'active');
1413
1791
  const verifiedRules = rules.filter(r => r.lastVerified && r.lastVerified !== '-');
1414
1792
 
1415
- // 7) 최근 in-progress / incomplete (우선 권장)
1416
- const focusNext = rows.filter(r => r.status === 'in-progress')
1417
- .concat(rows.filter(r => ['incomplete', 'blocked', 'waiting', 'on-hold'].includes(r.status)));
1793
+ // 7) 다음 우선 작업 — 우선순위: in-progress > blocked/waiting/on-hold/incomplete > planned/requested (1.9.14 C)
1794
+ const _priority = { 'in-progress': 0, 'blocked': 1, 'waiting': 1, 'on-hold': 1, 'incomplete': 1, 'planned': 2, 'requested': 2 };
1795
+ const focusNext = rows.filter(r => _priority[r.status] != null)
1796
+ .sort((a, b) => (_priority[a.status] || 9) - (_priority[b.status] || 9));
1418
1797
 
1419
1798
  return {
1420
1799
  statusCounts,
@@ -1456,11 +1835,49 @@ function _retroOneLine(agg) {
1456
1835
  return parts.join(' · ');
1457
1836
  }
1458
1837
 
1838
+ // 1.9.15: --all-apps / --include 경로 모음
1839
+ function _collectWorkspacePaths(rootBase) {
1840
+ const set = new Set();
1841
+ if (exists(path.join(rootBase, '.harness'))) set.add(rootBase);
1842
+ if (has('--all-apps')) {
1843
+ const baseCandidates = [path.resolve(rootBase, '_apps'), path.resolve(rootBase, '..', '_apps')];
1844
+ for (const base of baseCandidates) {
1845
+ if (!exists(base)) continue;
1846
+ let st; try { st = fs.statSync(base); } catch { continue; }
1847
+ if (!st.isDirectory()) continue;
1848
+ for (const e of fs.readdirSync(base)) {
1849
+ const p = path.join(base, e);
1850
+ try {
1851
+ if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness'))) set.add(p);
1852
+ } catch {}
1853
+ }
1854
+ }
1855
+ }
1856
+ const include = arg('--include', null);
1857
+ if (include) {
1858
+ for (const p of String(include).split(',')) {
1859
+ const abs = path.resolve(p.trim());
1860
+ if (exists(path.join(abs, '.harness'))) set.add(abs);
1861
+ else warn(`--include 무시: ${abs} (.harness 없음)`);
1862
+ }
1863
+ }
1864
+ return Array.from(set);
1865
+ }
1866
+
1459
1867
  function retroCmd(root) {
1460
1868
  root = absRoot(root);
1869
+ // 1.9.15: --all-apps / --include 통합 모드
1870
+ if (has('--all-apps') || arg('--include', null)) {
1871
+ return _retroWorkspace(root);
1872
+ }
1461
1873
  const days = parseInt(arg('--days', '7'), 10);
1462
1874
  const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10);
1463
1875
  const agg = _retroAggregate(root);
1876
+ // 1.9.16: --json
1877
+ if (has('--json')) {
1878
+ log(JSON.stringify({ project: path.basename(root), days, cutoff, summary: _retroOneLine(agg), data: agg }, null, 2));
1879
+ return;
1880
+ }
1464
1881
  log(`# 회고 (retro) — 최근 ${days}일 (since ${cutoff})`);
1465
1882
  log(`\n📈 한 줄 요약: ${_retroOneLine(agg)}`);
1466
1883
 
@@ -1506,9 +1923,66 @@ function retroCmd(root) {
1506
1923
  log(` 4. \`leerness brainstorm <주제>\`로 누적 데이터 기반 컨텍스트 적재`);
1507
1924
  }
1508
1925
 
1926
+ // 1.9.15: 워크스페이스 통합 retro (다수 프로젝트 묶음 회고)
1927
+ function _retroWorkspace(rootBase) {
1928
+ const paths = _collectWorkspacePaths(rootBase);
1929
+ if (!paths.length) return fail('대상 프로젝트 없음. --include <path1,path2> 또는 --all-apps 사용 필요.');
1930
+ // 1.9.16: --json
1931
+ if (has('--json')) {
1932
+ const projects = paths.map(p => {
1933
+ const a = _retroAggregate(p);
1934
+ return { project: path.basename(p), path: p, summary: _retroOneLine(a), data: a };
1935
+ });
1936
+ const totals = projects.reduce((t, p) => ({
1937
+ tasks: t.tasks + p.data.totalTasks, done: t.done + p.data.doneCount,
1938
+ decisions: t.decisions + p.data.decisionBlocks, skills: t.skills + p.data.skillUsage.length,
1939
+ usage: t.usage + p.data.totalSkillUsage, opts: t.opts + p.data.totalOptimizations,
1940
+ activeRules: t.activeRules + p.data.activeRules, pass: t.pass + p.data.passSignals, fix: t.fix + p.data.fixSignals
1941
+ }), { tasks: 0, done: 0, decisions: 0, skills: 0, usage: 0, opts: 0, activeRules: 0, pass: 0, fix: 0 });
1942
+ log(JSON.stringify({ projects, totals, projectCount: paths.length }, null, 2));
1943
+ return;
1944
+ }
1945
+ log(`# Cross-project retro — ${paths.length}개 프로젝트`);
1946
+ const totals = { tasks: 0, done: 0, decisions: 0, skills: 0, totalSkillUsage: 0, totalOpts: 0, activeRules: 0, fixSig: 0, passSig: 0 };
1947
+ for (const p of paths) {
1948
+ const agg = _retroAggregate(p);
1949
+ const name = path.basename(p);
1950
+ log(`\n## ${name}`);
1951
+ log(` 📈 ${_retroOneLine(agg)}`);
1952
+ const f = agg.focusNext[0];
1953
+ log(` 🎯 다음 우선: ${f ? `${f.id} [${f.status}] ${f.request.slice(0, 50)}` : '(없음)'}`);
1954
+ log(` 📚 top 스킬: ${agg.skillUsage.length ? agg.skillUsage[0].id + ' (' + agg.skillUsage[0].count + '회)' : '(없음)'}`);
1955
+ totals.tasks += agg.totalTasks;
1956
+ totals.done += agg.doneCount;
1957
+ totals.decisions += agg.decisionBlocks;
1958
+ totals.skills += agg.skillUsage.length;
1959
+ totals.totalSkillUsage += agg.totalSkillUsage;
1960
+ totals.totalOpts += agg.totalOptimizations;
1961
+ totals.activeRules += agg.activeRules;
1962
+ totals.fixSig += agg.fixSignals;
1963
+ totals.passSig += agg.passSignals;
1964
+ }
1965
+ log(`\n## 📊 워크스페이스 총합 (${paths.length} 프로젝트)`);
1966
+ log(` - 누적 task: ${totals.tasks}${totals.tasks ? ` (done ${totals.done} = ${Math.round(totals.done / totals.tasks * 100)}%)` : ''}`);
1967
+ log(` - 누적 결정: ${totals.decisions}건`);
1968
+ log(` - 스킬: ${totals.skills}종 / 사용 ${totals.totalSkillUsage}회 / 최적화 ${totals.totalOpts}건`);
1969
+ log(` - 활성 룰: ${totals.activeRules}건`);
1970
+ log(` - 시그널: pass ${totals.passSig} · fix ${totals.fixSig}${totals.passSig + totals.fixSig > 0 ? ` (비율 ${totals.fixSig ? (totals.passSig / totals.fixSig).toFixed(2) : '∞'})` : ''}`);
1971
+ }
1972
+
1509
1973
  function insightsCmd(root) {
1510
1974
  root = absRoot(root);
1975
+ // 1.9.15: --all-apps / --include 통합 모드
1976
+ if (has('--all-apps') || arg('--include', null)) {
1977
+ return _insightsWorkspace(root);
1978
+ }
1511
1979
  const agg = _retroAggregate(root);
1980
+ // 1.9.16: --json
1981
+ if (has('--json')) {
1982
+ const sc = readSessionCounter(root);
1983
+ log(JSON.stringify({ project: path.basename(root), sessionCount: sc.count, lastCloseAt: sc.lastCloseAt, data: agg }, null, 2));
1984
+ return;
1985
+ }
1512
1986
  const sc = readSessionCounter(root);
1513
1987
  log(`# Insights — 누적 통계`);
1514
1988
  log(`\n## 📊 핵심 지표`);
@@ -1543,22 +2017,182 @@ function insightsCmd(root) {
1543
2017
  if (agg.statusCounts.blocked > 0) log(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`);
1544
2018
  }
1545
2019
 
2020
+ function _insightsWorkspace(rootBase) {
2021
+ const paths = _collectWorkspacePaths(rootBase);
2022
+ if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
2023
+ // 1.9.16: --json
2024
+ if (has('--json')) {
2025
+ const projects = paths.map(p => ({ project: path.basename(p), path: p, data: _retroAggregate(p) }));
2026
+ log(JSON.stringify({ projects, projectCount: paths.length }, null, 2));
2027
+ return;
2028
+ }
2029
+ log(`# Workspace Insights — ${paths.length}개 프로젝트`);
2030
+ log(`\n| Project | Task | Done % | Decisions | Skills | Usage | Opts | Pass/Fix |`);
2031
+ log(`|---|---|---|---|---|---|---|---|`);
2032
+ const totals = { tasks: 0, done: 0, decisions: 0, skills: 0, usage: 0, opts: 0, pass: 0, fix: 0 };
2033
+ for (const p of paths) {
2034
+ const a = _retroAggregate(p);
2035
+ const donePct = a.totalTasks ? Math.round(a.doneCount / a.totalTasks * 100) : 0;
2036
+ const pf = a.fixSignals ? (a.passSignals / a.fixSignals).toFixed(1) : '∞';
2037
+ log(`| ${path.basename(p)} | ${a.totalTasks} | ${donePct}% | ${a.decisionBlocks} | ${a.skillUsage.length} | ${a.totalSkillUsage} | ${a.totalOptimizations} | ${a.passSignals}/${a.fixSignals} (${pf}) |`);
2038
+ totals.tasks += a.totalTasks; totals.done += a.doneCount; totals.decisions += a.decisionBlocks;
2039
+ totals.skills += a.skillUsage.length; totals.usage += a.totalSkillUsage; totals.opts += a.totalOptimizations;
2040
+ totals.pass += a.passSignals; totals.fix += a.fixSignals;
2041
+ }
2042
+ const tpf = totals.fix ? (totals.pass / totals.fix).toFixed(1) : '∞';
2043
+ const tDonePct = totals.tasks ? Math.round(totals.done / totals.tasks * 100) : 0;
2044
+ log(`| **TOTAL** | **${totals.tasks}** | **${tDonePct}%** | **${totals.decisions}** | **${totals.skills}** | **${totals.usage}** | **${totals.opts}** | **${totals.pass}/${totals.fix} (${tpf})** |`);
2045
+ log(`\n## 📈 평가`);
2046
+ if (totals.pass > totals.fix * 3) log(` - 안정성: 우수 (pass÷fix = ${tpf})`);
2047
+ else if (totals.pass > totals.fix) log(` - 안정성: 보통 (pass÷fix = ${tpf})`);
2048
+ else if (totals.fix > 0) log(` - 안정성: 주의 (fix가 pass보다 많음) — verify-code 자동화 검토`);
2049
+ if (totals.opts === 0) log(` - 최적화 누적 없음 — \`leerness skill optimize\` 활용 권장`);
2050
+ }
2051
+
2052
+ // 1.9.16: brainstorm 핵심 로직 분리 — 단일 프로젝트 결과 반환
2053
+ function _brainstormFor(root, topic) {
2054
+ function _escUnicode(s) { return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); }
2055
+ const tokens = String(topic).split(/\s+/).filter(t => t.length >= 2);
2056
+ const wordRes = tokens.map(t => new RegExp(`(?<![\\p{L}\\p{N}_])${_escUnicode(t)}(?![\\p{L}\\p{N}_])`, 'iu'));
2057
+ function matches(text) { return wordRes.every(re => re.test(text)); }
2058
+ const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
2059
+ const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
2060
+ const decLines = dec.split('\n');
2061
+ for (const b of _extractDecisionBlocks(dec)) {
2062
+ if (matches(b)) {
2063
+ const t = (b.match(/^### (.+)$/m) || [, ''])[1];
2064
+ const lineIdx = decLines.findIndex(line => line === `### ${t}`);
2065
+ const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
2066
+ hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
2067
+ }
2068
+ }
2069
+ const skillsDir = path.join(root, '.harness/skills');
2070
+ if (exists(skillsDir)) {
2071
+ for (const id of fs.readdirSync(skillsDir)) {
2072
+ const f = path.join(skillsDir, id, 'skill.json');
2073
+ if (!exists(f)) continue;
2074
+ try {
2075
+ const s = JSON.parse(read(f));
2076
+ if (matches(JSON.stringify(s))) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
2077
+ } catch {}
2078
+ }
2079
+ }
2080
+ const rows = readProgressRows(root);
2081
+ const progressText = exists(progressPath(root)) ? read(progressPath(root)) : '';
2082
+ for (const r of rows) {
2083
+ const fields = [];
2084
+ if (matches(r.request)) fields.push('request');
2085
+ if (matches(r.evidence)) fields.push('evidence');
2086
+ if (matches(r.nextAction)) fields.push('nextAction');
2087
+ if (fields.length) {
2088
+ const idx = progressText.indexOf(`| ${r.id} |`);
2089
+ const lineNo = idx >= 0 ? progressText.slice(0, idx).split('\n').length : 0;
2090
+ hits.tasks.push({ ...r, _fields: fields, line: lineNo });
2091
+ }
2092
+ }
2093
+ if (exists(rulesPath(root))) {
2094
+ const rulesText = read(rulesPath(root));
2095
+ for (const r of readRules(root)) {
2096
+ if (matches(r.rule)) {
2097
+ const idx = rulesText.indexOf(`| ${r.id} |`);
2098
+ const lineNo = idx >= 0 ? rulesText.slice(0, idx).split('\n').length : 0;
2099
+ hits.rules.push({ ...r, line: lineNo });
2100
+ }
2101
+ }
2102
+ }
2103
+ const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
2104
+ for (const block of ev.split(/\n(?=## )/)) {
2105
+ if (!block.startsWith('## ')) continue;
2106
+ if (matches(block)) {
2107
+ const t = (block.match(/^## (.+)$/m) || [, ''])[1];
2108
+ const idx = ev.indexOf(block);
2109
+ const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
2110
+ hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
2111
+ if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
2112
+ }
2113
+ }
2114
+ return hits;
2115
+ }
2116
+
2117
+ function _brainstormTotal(h) { return h.decisions.length + h.skills.length + h.tasks.length + h.rules.length + h.evidence.length; }
2118
+
2119
+ // 1.9.16: 워크스페이스 통합 brainstorm
2120
+ function _brainstormWorkspace(rootBase, topic) {
2121
+ const paths = _collectWorkspacePaths(rootBase);
2122
+ if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
2123
+ if (has('--json')) {
2124
+ const result = paths.map(p => ({ project: path.basename(p), path: p, hits: _brainstormFor(p, topic) }));
2125
+ log(JSON.stringify({ topic, projects: result, total: result.reduce((a, b) => a + _brainstormTotal(b.hits), 0) }, null, 2));
2126
+ return;
2127
+ }
2128
+ log(`# Cross-project Brainstorm — "${topic}" — ${paths.length}개 프로젝트`);
2129
+ let grandTotal = 0;
2130
+ for (const p of paths) {
2131
+ const h = _brainstormFor(p, topic);
2132
+ const n = _brainstormTotal(h);
2133
+ grandTotal += n;
2134
+ if (n === 0) continue;
2135
+ log(`\n## ${path.basename(p)} (${n}건)`);
2136
+ if (h.decisions.length) {
2137
+ log(` 🧠 결정 (${h.decisions.length})`);
2138
+ h.decisions.slice(0, 3).forEach(d => log(` - decisions.md:${d.line || '?'} — ${d.title}`));
2139
+ }
2140
+ if (h.skills.length) {
2141
+ log(` 📚 스킬 (${h.skills.length})`);
2142
+ h.skills.slice(0, 3).forEach(s => log(` - ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회`));
2143
+ }
2144
+ if (h.tasks.length) {
2145
+ log(` 📌 task (${h.tasks.length})`);
2146
+ h.tasks.slice(0, 3).forEach(t => log(` - progress-tracker.md:${t.line || '?'} — ${t.id} [${t.status}] ${t.request.slice(0, 50)} (matched: ${t._fields.join('+')})`));
2147
+ }
2148
+ if (h.rules.length) {
2149
+ log(` ⚡ 룰 (${h.rules.length})`);
2150
+ h.rules.slice(0, 3).forEach(r => log(` - rules.md:${r.line || '?'} — ${r.id} [${r.trigger}]`));
2151
+ }
2152
+ if (h.evidence.length) {
2153
+ log(` 🧪 evidence (${h.evidence.length})`);
2154
+ h.evidence.slice(0, 3).forEach(e => log(` - review-evidence.md:${e.line || '?'} — ${e.title}`));
2155
+ }
2156
+ if (h.lessons.length) {
2157
+ log(` ⚠ 과거 실패/롤백 (${h.lessons.length})`);
2158
+ }
2159
+ }
2160
+ log(`\n## 📊 워크스페이스 총합: ${grandTotal}건 매치 (${paths.length} 프로젝트)`);
2161
+ if (grandTotal === 0) log(` ⓘ 어느 프로젝트에서도 "${topic}" 관련 자원 없음 — 새 영역. 첫 결정/스킬을 기록하면 다음 brainstorm이 풍부해짐.`);
2162
+ }
2163
+
1546
2164
  function brainstormCmd(root, topic) {
1547
2165
  root = absRoot(root);
1548
2166
  if (!topic) return fail('topic required (e.g., brainstorm "API rate limit")');
2167
+ // 1.9.16: --all-apps / --include 통합 모드
2168
+ if (has('--all-apps') || arg('--include', null)) {
2169
+ return _brainstormWorkspace(root, topic);
2170
+ }
2171
+ // 1.9.16: --json 단일 프로젝트
2172
+ if (has('--json')) {
2173
+ const h = _brainstormFor(root, topic);
2174
+ log(JSON.stringify({ topic, project: path.basename(root), hits: h, total: _brainstormTotal(h) }, null, 2));
2175
+ return;
2176
+ }
1549
2177
  log(`# Brainstorm — "${topic}"`);
1550
2178
  log(`\n누적된 leerness 데이터에서 주제 관련 자원을 회수합니다.`);
1551
2179
 
1552
- const re = new RegExp(escapeRegex(topic), 'i');
2180
+ // 1.9.14 B: 토큰 기반 매칭 — unicode word boundary. unicode 모드에서 하이픈은 escape 불필요.
2181
+ function _escUnicode(s) { return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); }
2182
+ const tokens = String(topic).split(/\s+/).filter(t => t.length >= 2);
2183
+ const wordRes = tokens.map(t => new RegExp(`(?<![\\p{L}\\p{N}_])${_escUnicode(t)}(?![\\p{L}\\p{N}_])`, 'iu'));
2184
+ function matches(text) { return wordRes.every(re => re.test(text)); }
1553
2185
  const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
1554
2186
 
1555
- // decisions
2187
+ // decisions (1.9.14: 코드블록/Template 제외, 1.9.15: 라인 번호)
1556
2188
  const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1557
- for (const b of dec.split(/\n(?=### )/)) {
1558
- if (!b.startsWith('### ')) continue;
1559
- if (re.test(b)) {
2189
+ const decLines = dec.split('\n');
2190
+ for (const b of _extractDecisionBlocks(dec)) {
2191
+ if (matches(b)) {
1560
2192
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
1561
- hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' ') });
2193
+ const lineIdx = decLines.findIndex(line => line === `### ${t}`);
2194
+ const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
2195
+ hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
1562
2196
  }
1563
2197
  }
1564
2198
  // skills
@@ -1570,54 +2204,75 @@ function brainstormCmd(root, topic) {
1570
2204
  try {
1571
2205
  const s = JSON.parse(read(f));
1572
2206
  const text = JSON.stringify(s);
1573
- if (re.test(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
2207
+ if (matches(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
1574
2208
  } catch {}
1575
2209
  }
1576
2210
  }
1577
- // tasks
2211
+ // tasks (1.9.14: token 매칭, 1.9.15: 매치 필드 + 라인 번호)
1578
2212
  const rows = readProgressRows(root);
1579
- for (const r of rows) if (re.test(r.request) || re.test(r.evidence) || re.test(r.nextAction)) hits.tasks.push(r);
1580
- // rules
2213
+ const progressText = exists(progressPath(root)) ? read(progressPath(root)) : '';
2214
+ for (const r of rows) {
2215
+ const fields = [];
2216
+ if (matches(r.request)) fields.push('request');
2217
+ if (matches(r.evidence)) fields.push('evidence');
2218
+ if (matches(r.nextAction)) fields.push('nextAction');
2219
+ if (fields.length) {
2220
+ const idx = progressText.indexOf(`| ${r.id} |`);
2221
+ const lineNo = idx >= 0 ? progressText.slice(0, idx).split('\n').length : 0;
2222
+ hits.tasks.push({ ...r, _fields: fields, line: lineNo });
2223
+ }
2224
+ }
2225
+ // rules (1.9.15: 라인 번호)
1581
2226
  if (exists(rulesPath(root))) {
1582
- for (const r of readRules(root)) if (re.test(r.rule)) hits.rules.push(r);
2227
+ const rulesText = read(rulesPath(root));
2228
+ for (const r of readRules(root)) {
2229
+ if (matches(r.rule)) {
2230
+ const idx = rulesText.indexOf(`| ${r.id} |`);
2231
+ const lineNo = idx >= 0 ? rulesText.slice(0, idx).split('\n').length : 0;
2232
+ hits.rules.push({ ...r, line: lineNo });
2233
+ }
2234
+ }
1583
2235
  }
1584
- // evidence — lessons 키워드 (fail/롤백/incomplete) 동반
2236
+ // evidence — lessons 키워드 (fail/롤백/incomplete) 동반 (1.9.15: 라인 번호)
1585
2237
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1586
2238
  for (const block of ev.split(/\n(?=## )/)) {
1587
2239
  if (!block.startsWith('## ')) continue;
1588
- if (re.test(block)) {
2240
+ if (matches(block)) {
1589
2241
  const t = (block.match(/^## (.+)$/m) || [, ''])[1];
1590
- hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' ') });
1591
- if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim() });
2242
+ const idx = ev.indexOf(block);
2243
+ const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
2244
+ hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
2245
+ if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
1592
2246
  }
1593
2247
  }
1594
2248
 
1595
2249
  const total = hits.decisions.length + hits.skills.length + hits.tasks.length + hits.rules.length + hits.evidence.length;
1596
2250
  log(`\n📦 총 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length})`);
1597
2251
 
2252
+ // 1.9.15: 모든 출력에 출처 파일:라인 표시
1598
2253
  if (hits.decisions.length) {
1599
2254
  log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
1600
- hits.decisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
2255
+ hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${d.title}`));
1601
2256
  }
1602
2257
  if (hits.skills.length) {
1603
2258
  log(`\n## 📚 관련 스킬 (${hits.skills.length}) — 시작 전 \`skill info <id>\` 권장`);
1604
- hits.skills.forEach(s => log(` - ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`));
2259
+ hits.skills.forEach(s => log(` - .harness/skills/${s.id}/skill.json — ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`));
1605
2260
  }
1606
2261
  if (hits.tasks.length) {
1607
2262
  log(`\n## 📌 관련 과거 task (${hits.tasks.length})`);
1608
- hits.tasks.slice(0, 5).forEach(t => log(` - ${t.id} [${t.status}] ${t.request}`));
2263
+ hits.tasks.slice(0, 5).forEach(t => log(` - .harness/progress-tracker.md:${t.line || '?'} — ${t.id} [${t.status}] ${t.request} (matched: ${t._fields.join('+')})`));
1609
2264
  }
1610
2265
  if (hits.rules.length) {
1611
2266
  log(`\n## ⚡ 관련 룰 (${hits.rules.length})`);
1612
- hits.rules.forEach(r => log(` - ${r.id} [${r.trigger}] ${r.rule}`));
2267
+ hits.rules.forEach(r => log(` - .harness/rules.md:${r.line || '?'} — ${r.id} [${r.trigger}] ${r.rule}`));
1613
2268
  }
1614
2269
  if (hits.evidence.length) {
1615
2270
  log(`\n## 🧪 관련 검증 기록 (${hits.evidence.length})`);
1616
- hits.evidence.slice(0, 5).forEach(e => log(` - ${e.title}`));
2271
+ hits.evidence.slice(0, 5).forEach(e => log(` - .harness/review-evidence.md:${e.line || '?'} — ${e.title}`));
1617
2272
  }
1618
2273
  if (hits.lessons.length) {
1619
2274
  log(`\n## ⚠ 같은 주제 과거 실패/롤백 (${hits.lessons.length}) — 같은 실수 방지`);
1620
- hits.lessons.slice(0, 5).forEach(l => log(` - ${l.title}`));
2275
+ hits.lessons.slice(0, 5).forEach(l => log(` - .harness/review-evidence.md:${l.line || '?'} — ${l.title}`));
1621
2276
  }
1622
2277
 
1623
2278
  log(`\n## 💡 시작 전 권장 액션`);
@@ -2434,9 +3089,8 @@ function lessonsCmd(root) {
2434
3089
  const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
2435
3090
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
2436
3091
  const lessons = [];
2437
- // decisions: ### 블록 전체
2438
- for (const block of decisions.split(/\n(?=### )/)) {
2439
- if (!block.startsWith('### ')) continue;
3092
+ // decisions: ### 블록 전체 (1.9.14: 코드블록/Template 제외)
3093
+ for (const block of _extractDecisionBlocks(decisions)) {
2440
3094
  const m = block.match(/^### (.+)$/m);
2441
3095
  if (!m) continue;
2442
3096
  lessons.push({ source: 'decisions.md', title: m[1].trim(), block });
@@ -2894,10 +3548,10 @@ function viewworkInstall(root) {
2894
3548
  }
2895
3549
 
2896
3550
  function help() {
2897
- 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
2898
- leerness retro [path] [--days 7] # 회고 (1.9.13) — 작업/스킬/결정/검증 시간 추세 + 권장
2899
- leerness insights [path] # 누적 통계 (1.9.13) — 핵심 지표 + 안정성
2900
- leerness brainstorm "<주제>" # 브레인스토밍 (1.9.13) — 누적 자원 회수 + 시작 컨텍스트
3551
+ 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 .] [--json] # 1.9.18 progress evidence 자동 검증\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
3552
+ leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
3553
+ leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
3554
+ leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
2901
3555
  leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
2902
3556
  leerness roadmap auto on|off|status [--on-every-change] [--out file.html] # 자동 갱신 (1.9.12, install/session-close 기본 ON)
2903
3557
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
@@ -2927,7 +3581,9 @@ async function main() {
2927
3581
  if (cmd === 'encoding' && args[1] === 'check') return encodingCheck(args[2] || process.cwd());
2928
3582
  if (cmd === 'lazy' && args[1] === 'detect') return lazyDetect(args[2] || process.cwd());
2929
3583
  if (cmd === 'memory' && args[1] === 'search') return memorySearch(arg('--path', process.cwd()), args.slice(2).join(' '));
2930
- if (cmd === 'handoff') return handoff(args[1] || process.cwd());
3584
+ if (cmd === 'handoff') return handoffCmd(args[1] || process.cwd());
3585
+ if (cmd === 'reuse-map') return reuseMapCmd(args[1] || process.cwd());
3586
+ if (cmd === 'verify-claim') return verifyClaimCmd(arg('--path', process.cwd()), args[1]);
2931
3587
  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; }
2932
3588
  if (cmd === 'viewwork' && args[1] === 'install') return viewworkInstall(args[2] || process.cwd());
2933
3589
  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') });