leerness 1.9.13 → 1.9.16

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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.16 — 2026-05-13
4
+
5
+ **brainstorm 워크스페이스 통합 + 3 명령 JSON export + session close 워크스페이스 안내**.
6
+
7
+ ### Added
8
+
9
+ - **`leerness brainstorm "<주제>" --all-apps` / `--include`**: retro/insights에 이어 brainstorm도 다수 프로젝트 통합 검색. 프로젝트별 결과 요약 + 워크스페이스 총합.
10
+ - **`--json` 옵션** (retro / insights / brainstorm, 단일/워크스페이스 모두): JSON으로 export. CI/대시보드 연동 가능.
11
+ - **session close 끝에 워크스페이스 안내**: `_apps/*/` 또는 부모 `_apps/*/`에 다른 leerness 프로젝트가 있으면 `🌐 워크스페이스에 N개 — leerness retro --all-apps` 자동 안내.
12
+
13
+ ### Migration
14
+ ```bash
15
+ npx leerness@latest update . --yes
16
+ ```
17
+
18
+ ## 1.9.15 — 2026-05-13
19
+
20
+ **브레인스토밍 출처 표시 + 워크스페이스 통합 회고/통찰**.
21
+
22
+ ### Added
23
+
24
+ - **brainstorm 매치 위치 표시**: 모든 결과에 `.harness/<file>:<line>` 형식의 파일 경로 + 줄 번호. task 결과는 매치된 필드(request/evidence/nextAction)도 함께 표시.
25
+ - **`leerness retro --all-apps`**: 현재 디렉토리 + `_apps/*` (또는 부모 `_apps/*`)의 모든 leerness 프로젝트를 통합 회고. 프로젝트별 한 줄 요약 + 다음 우선 작업 + top 스킬 + 워크스페이스 총합 (task / done % / 결정 / 스킬 / 사용 / 최적화 / pass-fix 비율).
26
+ - **`leerness retro --include <p1,p2,...>`**: 명시 경로 통합 회고. 쉼표 구분 다중 경로 지원.
27
+ - **`leerness insights --all-apps`** / **`--include`**: 통합 통계를 표 형식으로 출력 + 안정성 평가 + 최적화 권장.
28
+
29
+ ### Migration
30
+
31
+ ```bash
32
+ npx leerness@latest update . --yes
33
+ ```
34
+
35
+ 기존 명령은 모두 호환. `--all-apps` / `--include`는 선택 옵션.
36
+
37
+ ## 1.9.14 — 2026-05-13
38
+
39
+ **1.9.13의 retro/brainstorm 정확도 4건 fix** (city-insights 대형 프로젝트 운영 중 발견).
40
+
41
+ ### Fixed
42
+
43
+ - **A. decisions Template 카운트 오류**: init이 만드는 `decisions.md`의 `### YYYY-MM-DD — Decision` 템플릿 예시가 실제 결정으로 잘못 카운트되던 문제. `_extractDecisionBlocks()` 헬퍼가 코드블록(```...```) 안의 ### 와 `### (Template|템플릿)` 시작 블록을 자동 제외.
44
+ - **B. brainstorm 토큰 매칭 부정확**: 단순 substring 매치로 인해 무관한 task가 잡히던 문제. **유니코드 word boundary** (`(?<![\p{L}\p{N}_])…(?![\p{L}\p{N}_])`) 기반 토큰 매칭으로 변경. 다중 토큰 (예: `"API rate limit"`)은 **모두** 매치되어야 결과로 표시.
45
+ - **C. retro 다음 우선 작업이 planned 미포함**: in-progress/blocked가 비어있으면 "(없음)"으로 표시되던 문제. 우선순위 가중치 (in-progress=0, blocked/waiting/on-hold/incomplete=1, planned/requested=2)로 정렬해 planned도 포함.
46
+ - **D. decisions.md 템플릿 형식**: init 디폴트가 실 결정과 동일한 `### YYYY-MM-DD — Decision` 형식이라 retro 카운트와 충돌. 템플릿을 **명시적 ```` ```md ```` 코드블록**으로 감싸 표시. retro/brainstorm/lessons가 일관되게 무시.
47
+
48
+ ### Migration
49
+
50
+ ```bash
51
+ npx leerness@latest update . --yes
52
+ ```
53
+
54
+ 기존 프로젝트의 decisions.md는 그대로 두면 자동으로 정확히 카운트됩니다 (코드블록 처리는 양쪽 모두 동작).
55
+
3
56
  ## 1.9.13 — 2026-05-13
4
57
 
5
58
  **회고·통찰·브레인스토밍** — 누적된 leerness 데이터에서 자동으로 패턴/추세/주제별 자원을 추출.
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.16';
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']);
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`),
@@ -1297,6 +1297,22 @@ function sessionClose(root) {
1297
1297
  const left = 5 - (sc.count % 5);
1298
1298
  log(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`);
1299
1299
  }
1300
+ // 1.9.16: 워크스페이스 안내 (다른 leerness 프로젝트가 있으면)
1301
+ try {
1302
+ const wsCands = [path.resolve(root, '_apps'), path.resolve(root, '..', '_apps')];
1303
+ let wsCount = 0;
1304
+ for (const base of wsCands) {
1305
+ if (!exists(base)) continue;
1306
+ try { if (!fs.statSync(base).isDirectory()) continue; } catch { continue; }
1307
+ for (const e of fs.readdirSync(base)) {
1308
+ try {
1309
+ const p = path.join(base, e);
1310
+ if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness')) && p !== root) wsCount++;
1311
+ } catch {}
1312
+ }
1313
+ }
1314
+ if (wsCount > 0) log(` 🌐 워크스페이스에 ${wsCount}개 다른 leerness 프로젝트 — \`leerness retro --all-apps\`로 통합 회고`);
1315
+ } catch {}
1300
1316
  } catch (e) {
1301
1317
  warn('retro 요약 실패: ' + (e && e.message ? e.message : e));
1302
1318
  }
@@ -1356,6 +1372,15 @@ function readSessionCounter(root) {
1356
1372
  }
1357
1373
  function writeSessionCounter(root, c) { writeUtf8(sessionCounterPath(root), JSON.stringify(c, null, 2) + '\n'); }
1358
1374
 
1375
+ // 1.9.14 A/D: 결정 블록 추출 — 코드 블록 안의 ### + Template 제외
1376
+ function _extractDecisionBlocks(text) {
1377
+ // 줄 시작의 ```부터 줄 시작의 ```까지를 코드블록으로 인식 (인라인 백틱 무시)
1378
+ const cleaned = String(text || '').replace(/^```[^\n]*\n[\s\S]*?\n```\s*$/gm, '');
1379
+ return cleaned.split(/\n(?=### )/).filter(b =>
1380
+ b.startsWith('### ') && !/^### (Template|템플릿)\b/.test(b.trim())
1381
+ );
1382
+ }
1383
+
1359
1384
  function _retroAggregate(root) {
1360
1385
  root = absRoot(root);
1361
1386
  const rows = readProgressRows(root);
@@ -1369,8 +1394,8 @@ function _retroAggregate(root) {
1369
1394
  for (const s of STATUSES) statusCounts[s] = 0;
1370
1395
  for (const r of rows) if (statusCounts[r.status] != null) statusCounts[r.status]++;
1371
1396
 
1372
- // 2) 결정 블록 수
1373
- const decisionBlocks = decisions.split(/\n(?=### )/).filter(b => b.startsWith('### '));
1397
+ // 2) 결정 블록 수 (1.9.14: 코드블록/Template 제외)
1398
+ const decisionBlocks = _extractDecisionBlocks(decisions);
1374
1399
  // recent decisions (날짜로 정렬 시 가장 최근)
1375
1400
  const recentDecisions = decisionBlocks.slice(-5).map(b => {
1376
1401
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
@@ -1412,9 +1437,10 @@ function _retroAggregate(root) {
1412
1437
  const activeRules = rules.filter(r => r.status === 'active');
1413
1438
  const verifiedRules = rules.filter(r => r.lastVerified && r.lastVerified !== '-');
1414
1439
 
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)));
1440
+ // 7) 다음 우선 작업 — 우선순위: in-progress > blocked/waiting/on-hold/incomplete > planned/requested (1.9.14 C)
1441
+ const _priority = { 'in-progress': 0, 'blocked': 1, 'waiting': 1, 'on-hold': 1, 'incomplete': 1, 'planned': 2, 'requested': 2 };
1442
+ const focusNext = rows.filter(r => _priority[r.status] != null)
1443
+ .sort((a, b) => (_priority[a.status] || 9) - (_priority[b.status] || 9));
1418
1444
 
1419
1445
  return {
1420
1446
  statusCounts,
@@ -1456,11 +1482,49 @@ function _retroOneLine(agg) {
1456
1482
  return parts.join(' · ');
1457
1483
  }
1458
1484
 
1485
+ // 1.9.15: --all-apps / --include 경로 모음
1486
+ function _collectWorkspacePaths(rootBase) {
1487
+ const set = new Set();
1488
+ if (exists(path.join(rootBase, '.harness'))) set.add(rootBase);
1489
+ if (has('--all-apps')) {
1490
+ const baseCandidates = [path.resolve(rootBase, '_apps'), path.resolve(rootBase, '..', '_apps')];
1491
+ for (const base of baseCandidates) {
1492
+ if (!exists(base)) continue;
1493
+ let st; try { st = fs.statSync(base); } catch { continue; }
1494
+ if (!st.isDirectory()) continue;
1495
+ for (const e of fs.readdirSync(base)) {
1496
+ const p = path.join(base, e);
1497
+ try {
1498
+ if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness'))) set.add(p);
1499
+ } catch {}
1500
+ }
1501
+ }
1502
+ }
1503
+ const include = arg('--include', null);
1504
+ if (include) {
1505
+ for (const p of String(include).split(',')) {
1506
+ const abs = path.resolve(p.trim());
1507
+ if (exists(path.join(abs, '.harness'))) set.add(abs);
1508
+ else warn(`--include 무시: ${abs} (.harness 없음)`);
1509
+ }
1510
+ }
1511
+ return Array.from(set);
1512
+ }
1513
+
1459
1514
  function retroCmd(root) {
1460
1515
  root = absRoot(root);
1516
+ // 1.9.15: --all-apps / --include 통합 모드
1517
+ if (has('--all-apps') || arg('--include', null)) {
1518
+ return _retroWorkspace(root);
1519
+ }
1461
1520
  const days = parseInt(arg('--days', '7'), 10);
1462
1521
  const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10);
1463
1522
  const agg = _retroAggregate(root);
1523
+ // 1.9.16: --json
1524
+ if (has('--json')) {
1525
+ log(JSON.stringify({ project: path.basename(root), days, cutoff, summary: _retroOneLine(agg), data: agg }, null, 2));
1526
+ return;
1527
+ }
1464
1528
  log(`# 회고 (retro) — 최근 ${days}일 (since ${cutoff})`);
1465
1529
  log(`\n📈 한 줄 요약: ${_retroOneLine(agg)}`);
1466
1530
 
@@ -1506,9 +1570,66 @@ function retroCmd(root) {
1506
1570
  log(` 4. \`leerness brainstorm <주제>\`로 누적 데이터 기반 컨텍스트 적재`);
1507
1571
  }
1508
1572
 
1573
+ // 1.9.15: 워크스페이스 통합 retro (다수 프로젝트 묶음 회고)
1574
+ function _retroWorkspace(rootBase) {
1575
+ const paths = _collectWorkspacePaths(rootBase);
1576
+ if (!paths.length) return fail('대상 프로젝트 없음. --include <path1,path2> 또는 --all-apps 사용 필요.');
1577
+ // 1.9.16: --json
1578
+ if (has('--json')) {
1579
+ const projects = paths.map(p => {
1580
+ const a = _retroAggregate(p);
1581
+ return { project: path.basename(p), path: p, summary: _retroOneLine(a), data: a };
1582
+ });
1583
+ const totals = projects.reduce((t, p) => ({
1584
+ tasks: t.tasks + p.data.totalTasks, done: t.done + p.data.doneCount,
1585
+ decisions: t.decisions + p.data.decisionBlocks, skills: t.skills + p.data.skillUsage.length,
1586
+ usage: t.usage + p.data.totalSkillUsage, opts: t.opts + p.data.totalOptimizations,
1587
+ activeRules: t.activeRules + p.data.activeRules, pass: t.pass + p.data.passSignals, fix: t.fix + p.data.fixSignals
1588
+ }), { tasks: 0, done: 0, decisions: 0, skills: 0, usage: 0, opts: 0, activeRules: 0, pass: 0, fix: 0 });
1589
+ log(JSON.stringify({ projects, totals, projectCount: paths.length }, null, 2));
1590
+ return;
1591
+ }
1592
+ log(`# Cross-project retro — ${paths.length}개 프로젝트`);
1593
+ const totals = { tasks: 0, done: 0, decisions: 0, skills: 0, totalSkillUsage: 0, totalOpts: 0, activeRules: 0, fixSig: 0, passSig: 0 };
1594
+ for (const p of paths) {
1595
+ const agg = _retroAggregate(p);
1596
+ const name = path.basename(p);
1597
+ log(`\n## ${name}`);
1598
+ log(` 📈 ${_retroOneLine(agg)}`);
1599
+ const f = agg.focusNext[0];
1600
+ log(` 🎯 다음 우선: ${f ? `${f.id} [${f.status}] ${f.request.slice(0, 50)}` : '(없음)'}`);
1601
+ log(` 📚 top 스킬: ${agg.skillUsage.length ? agg.skillUsage[0].id + ' (' + agg.skillUsage[0].count + '회)' : '(없음)'}`);
1602
+ totals.tasks += agg.totalTasks;
1603
+ totals.done += agg.doneCount;
1604
+ totals.decisions += agg.decisionBlocks;
1605
+ totals.skills += agg.skillUsage.length;
1606
+ totals.totalSkillUsage += agg.totalSkillUsage;
1607
+ totals.totalOpts += agg.totalOptimizations;
1608
+ totals.activeRules += agg.activeRules;
1609
+ totals.fixSig += agg.fixSignals;
1610
+ totals.passSig += agg.passSignals;
1611
+ }
1612
+ log(`\n## 📊 워크스페이스 총합 (${paths.length} 프로젝트)`);
1613
+ log(` - 누적 task: ${totals.tasks}${totals.tasks ? ` (done ${totals.done} = ${Math.round(totals.done / totals.tasks * 100)}%)` : ''}`);
1614
+ log(` - 누적 결정: ${totals.decisions}건`);
1615
+ log(` - 스킬: ${totals.skills}종 / 사용 ${totals.totalSkillUsage}회 / 최적화 ${totals.totalOpts}건`);
1616
+ log(` - 활성 룰: ${totals.activeRules}건`);
1617
+ log(` - 시그널: pass ${totals.passSig} · fix ${totals.fixSig}${totals.passSig + totals.fixSig > 0 ? ` (비율 ${totals.fixSig ? (totals.passSig / totals.fixSig).toFixed(2) : '∞'})` : ''}`);
1618
+ }
1619
+
1509
1620
  function insightsCmd(root) {
1510
1621
  root = absRoot(root);
1622
+ // 1.9.15: --all-apps / --include 통합 모드
1623
+ if (has('--all-apps') || arg('--include', null)) {
1624
+ return _insightsWorkspace(root);
1625
+ }
1511
1626
  const agg = _retroAggregate(root);
1627
+ // 1.9.16: --json
1628
+ if (has('--json')) {
1629
+ const sc = readSessionCounter(root);
1630
+ log(JSON.stringify({ project: path.basename(root), sessionCount: sc.count, lastCloseAt: sc.lastCloseAt, data: agg }, null, 2));
1631
+ return;
1632
+ }
1512
1633
  const sc = readSessionCounter(root);
1513
1634
  log(`# Insights — 누적 통계`);
1514
1635
  log(`\n## 📊 핵심 지표`);
@@ -1543,22 +1664,182 @@ function insightsCmd(root) {
1543
1664
  if (agg.statusCounts.blocked > 0) log(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`);
1544
1665
  }
1545
1666
 
1667
+ function _insightsWorkspace(rootBase) {
1668
+ const paths = _collectWorkspacePaths(rootBase);
1669
+ if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
1670
+ // 1.9.16: --json
1671
+ if (has('--json')) {
1672
+ const projects = paths.map(p => ({ project: path.basename(p), path: p, data: _retroAggregate(p) }));
1673
+ log(JSON.stringify({ projects, projectCount: paths.length }, null, 2));
1674
+ return;
1675
+ }
1676
+ log(`# Workspace Insights — ${paths.length}개 프로젝트`);
1677
+ log(`\n| Project | Task | Done % | Decisions | Skills | Usage | Opts | Pass/Fix |`);
1678
+ log(`|---|---|---|---|---|---|---|---|`);
1679
+ const totals = { tasks: 0, done: 0, decisions: 0, skills: 0, usage: 0, opts: 0, pass: 0, fix: 0 };
1680
+ for (const p of paths) {
1681
+ const a = _retroAggregate(p);
1682
+ const donePct = a.totalTasks ? Math.round(a.doneCount / a.totalTasks * 100) : 0;
1683
+ const pf = a.fixSignals ? (a.passSignals / a.fixSignals).toFixed(1) : '∞';
1684
+ log(`| ${path.basename(p)} | ${a.totalTasks} | ${donePct}% | ${a.decisionBlocks} | ${a.skillUsage.length} | ${a.totalSkillUsage} | ${a.totalOptimizations} | ${a.passSignals}/${a.fixSignals} (${pf}) |`);
1685
+ totals.tasks += a.totalTasks; totals.done += a.doneCount; totals.decisions += a.decisionBlocks;
1686
+ totals.skills += a.skillUsage.length; totals.usage += a.totalSkillUsage; totals.opts += a.totalOptimizations;
1687
+ totals.pass += a.passSignals; totals.fix += a.fixSignals;
1688
+ }
1689
+ const tpf = totals.fix ? (totals.pass / totals.fix).toFixed(1) : '∞';
1690
+ const tDonePct = totals.tasks ? Math.round(totals.done / totals.tasks * 100) : 0;
1691
+ log(`| **TOTAL** | **${totals.tasks}** | **${tDonePct}%** | **${totals.decisions}** | **${totals.skills}** | **${totals.usage}** | **${totals.opts}** | **${totals.pass}/${totals.fix} (${tpf})** |`);
1692
+ log(`\n## 📈 평가`);
1693
+ if (totals.pass > totals.fix * 3) log(` - 안정성: 우수 (pass÷fix = ${tpf})`);
1694
+ else if (totals.pass > totals.fix) log(` - 안정성: 보통 (pass÷fix = ${tpf})`);
1695
+ else if (totals.fix > 0) log(` - 안정성: 주의 (fix가 pass보다 많음) — verify-code 자동화 검토`);
1696
+ if (totals.opts === 0) log(` - 최적화 누적 없음 — \`leerness skill optimize\` 활용 권장`);
1697
+ }
1698
+
1699
+ // 1.9.16: brainstorm 핵심 로직 분리 — 단일 프로젝트 결과 반환
1700
+ function _brainstormFor(root, topic) {
1701
+ function _escUnicode(s) { return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); }
1702
+ const tokens = String(topic).split(/\s+/).filter(t => t.length >= 2);
1703
+ const wordRes = tokens.map(t => new RegExp(`(?<![\\p{L}\\p{N}_])${_escUnicode(t)}(?![\\p{L}\\p{N}_])`, 'iu'));
1704
+ function matches(text) { return wordRes.every(re => re.test(text)); }
1705
+ const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
1706
+ const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1707
+ const decLines = dec.split('\n');
1708
+ for (const b of _extractDecisionBlocks(dec)) {
1709
+ if (matches(b)) {
1710
+ const t = (b.match(/^### (.+)$/m) || [, ''])[1];
1711
+ const lineIdx = decLines.findIndex(line => line === `### ${t}`);
1712
+ const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
1713
+ hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
1714
+ }
1715
+ }
1716
+ const skillsDir = path.join(root, '.harness/skills');
1717
+ if (exists(skillsDir)) {
1718
+ for (const id of fs.readdirSync(skillsDir)) {
1719
+ const f = path.join(skillsDir, id, 'skill.json');
1720
+ if (!exists(f)) continue;
1721
+ try {
1722
+ const s = JSON.parse(read(f));
1723
+ if (matches(JSON.stringify(s))) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
1724
+ } catch {}
1725
+ }
1726
+ }
1727
+ const rows = readProgressRows(root);
1728
+ const progressText = exists(progressPath(root)) ? read(progressPath(root)) : '';
1729
+ for (const r of rows) {
1730
+ const fields = [];
1731
+ if (matches(r.request)) fields.push('request');
1732
+ if (matches(r.evidence)) fields.push('evidence');
1733
+ if (matches(r.nextAction)) fields.push('nextAction');
1734
+ if (fields.length) {
1735
+ const idx = progressText.indexOf(`| ${r.id} |`);
1736
+ const lineNo = idx >= 0 ? progressText.slice(0, idx).split('\n').length : 0;
1737
+ hits.tasks.push({ ...r, _fields: fields, line: lineNo });
1738
+ }
1739
+ }
1740
+ if (exists(rulesPath(root))) {
1741
+ const rulesText = read(rulesPath(root));
1742
+ for (const r of readRules(root)) {
1743
+ if (matches(r.rule)) {
1744
+ const idx = rulesText.indexOf(`| ${r.id} |`);
1745
+ const lineNo = idx >= 0 ? rulesText.slice(0, idx).split('\n').length : 0;
1746
+ hits.rules.push({ ...r, line: lineNo });
1747
+ }
1748
+ }
1749
+ }
1750
+ const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1751
+ for (const block of ev.split(/\n(?=## )/)) {
1752
+ if (!block.startsWith('## ')) continue;
1753
+ if (matches(block)) {
1754
+ const t = (block.match(/^## (.+)$/m) || [, ''])[1];
1755
+ const idx = ev.indexOf(block);
1756
+ const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
1757
+ hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
1758
+ if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
1759
+ }
1760
+ }
1761
+ return hits;
1762
+ }
1763
+
1764
+ function _brainstormTotal(h) { return h.decisions.length + h.skills.length + h.tasks.length + h.rules.length + h.evidence.length; }
1765
+
1766
+ // 1.9.16: 워크스페이스 통합 brainstorm
1767
+ function _brainstormWorkspace(rootBase, topic) {
1768
+ const paths = _collectWorkspacePaths(rootBase);
1769
+ if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
1770
+ if (has('--json')) {
1771
+ const result = paths.map(p => ({ project: path.basename(p), path: p, hits: _brainstormFor(p, topic) }));
1772
+ log(JSON.stringify({ topic, projects: result, total: result.reduce((a, b) => a + _brainstormTotal(b.hits), 0) }, null, 2));
1773
+ return;
1774
+ }
1775
+ log(`# Cross-project Brainstorm — "${topic}" — ${paths.length}개 프로젝트`);
1776
+ let grandTotal = 0;
1777
+ for (const p of paths) {
1778
+ const h = _brainstormFor(p, topic);
1779
+ const n = _brainstormTotal(h);
1780
+ grandTotal += n;
1781
+ if (n === 0) continue;
1782
+ log(`\n## ${path.basename(p)} (${n}건)`);
1783
+ if (h.decisions.length) {
1784
+ log(` 🧠 결정 (${h.decisions.length})`);
1785
+ h.decisions.slice(0, 3).forEach(d => log(` - decisions.md:${d.line || '?'} — ${d.title}`));
1786
+ }
1787
+ if (h.skills.length) {
1788
+ log(` 📚 스킬 (${h.skills.length})`);
1789
+ h.skills.slice(0, 3).forEach(s => log(` - ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회`));
1790
+ }
1791
+ if (h.tasks.length) {
1792
+ log(` 📌 task (${h.tasks.length})`);
1793
+ 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('+')})`));
1794
+ }
1795
+ if (h.rules.length) {
1796
+ log(` ⚡ 룰 (${h.rules.length})`);
1797
+ h.rules.slice(0, 3).forEach(r => log(` - rules.md:${r.line || '?'} — ${r.id} [${r.trigger}]`));
1798
+ }
1799
+ if (h.evidence.length) {
1800
+ log(` 🧪 evidence (${h.evidence.length})`);
1801
+ h.evidence.slice(0, 3).forEach(e => log(` - review-evidence.md:${e.line || '?'} — ${e.title}`));
1802
+ }
1803
+ if (h.lessons.length) {
1804
+ log(` ⚠ 과거 실패/롤백 (${h.lessons.length})`);
1805
+ }
1806
+ }
1807
+ log(`\n## 📊 워크스페이스 총합: ${grandTotal}건 매치 (${paths.length} 프로젝트)`);
1808
+ if (grandTotal === 0) log(` ⓘ 어느 프로젝트에서도 "${topic}" 관련 자원 없음 — 새 영역. 첫 결정/스킬을 기록하면 다음 brainstorm이 풍부해짐.`);
1809
+ }
1810
+
1546
1811
  function brainstormCmd(root, topic) {
1547
1812
  root = absRoot(root);
1548
1813
  if (!topic) return fail('topic required (e.g., brainstorm "API rate limit")');
1814
+ // 1.9.16: --all-apps / --include 통합 모드
1815
+ if (has('--all-apps') || arg('--include', null)) {
1816
+ return _brainstormWorkspace(root, topic);
1817
+ }
1818
+ // 1.9.16: --json 단일 프로젝트
1819
+ if (has('--json')) {
1820
+ const h = _brainstormFor(root, topic);
1821
+ log(JSON.stringify({ topic, project: path.basename(root), hits: h, total: _brainstormTotal(h) }, null, 2));
1822
+ return;
1823
+ }
1549
1824
  log(`# Brainstorm — "${topic}"`);
1550
1825
  log(`\n누적된 leerness 데이터에서 주제 관련 자원을 회수합니다.`);
1551
1826
 
1552
- const re = new RegExp(escapeRegex(topic), 'i');
1827
+ // 1.9.14 B: 토큰 기반 매칭 — unicode word boundary. unicode 모드에서 하이픈은 escape 불필요.
1828
+ function _escUnicode(s) { return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); }
1829
+ const tokens = String(topic).split(/\s+/).filter(t => t.length >= 2);
1830
+ const wordRes = tokens.map(t => new RegExp(`(?<![\\p{L}\\p{N}_])${_escUnicode(t)}(?![\\p{L}\\p{N}_])`, 'iu'));
1831
+ function matches(text) { return wordRes.every(re => re.test(text)); }
1553
1832
  const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
1554
1833
 
1555
- // decisions
1834
+ // decisions (1.9.14: 코드블록/Template 제외, 1.9.15: 라인 번호)
1556
1835
  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)) {
1836
+ const decLines = dec.split('\n');
1837
+ for (const b of _extractDecisionBlocks(dec)) {
1838
+ if (matches(b)) {
1560
1839
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
1561
- hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' ') });
1840
+ const lineIdx = decLines.findIndex(line => line === `### ${t}`);
1841
+ const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
1842
+ hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
1562
1843
  }
1563
1844
  }
1564
1845
  // skills
@@ -1570,54 +1851,75 @@ function brainstormCmd(root, topic) {
1570
1851
  try {
1571
1852
  const s = JSON.parse(read(f));
1572
1853
  const text = JSON.stringify(s);
1573
- if (re.test(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
1854
+ if (matches(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
1574
1855
  } catch {}
1575
1856
  }
1576
1857
  }
1577
- // tasks
1858
+ // tasks (1.9.14: token 매칭, 1.9.15: 매치 필드 + 라인 번호)
1578
1859
  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
1860
+ const progressText = exists(progressPath(root)) ? read(progressPath(root)) : '';
1861
+ for (const r of rows) {
1862
+ const fields = [];
1863
+ if (matches(r.request)) fields.push('request');
1864
+ if (matches(r.evidence)) fields.push('evidence');
1865
+ if (matches(r.nextAction)) fields.push('nextAction');
1866
+ if (fields.length) {
1867
+ const idx = progressText.indexOf(`| ${r.id} |`);
1868
+ const lineNo = idx >= 0 ? progressText.slice(0, idx).split('\n').length : 0;
1869
+ hits.tasks.push({ ...r, _fields: fields, line: lineNo });
1870
+ }
1871
+ }
1872
+ // rules (1.9.15: 라인 번호)
1581
1873
  if (exists(rulesPath(root))) {
1582
- for (const r of readRules(root)) if (re.test(r.rule)) hits.rules.push(r);
1874
+ const rulesText = read(rulesPath(root));
1875
+ for (const r of readRules(root)) {
1876
+ if (matches(r.rule)) {
1877
+ const idx = rulesText.indexOf(`| ${r.id} |`);
1878
+ const lineNo = idx >= 0 ? rulesText.slice(0, idx).split('\n').length : 0;
1879
+ hits.rules.push({ ...r, line: lineNo });
1880
+ }
1881
+ }
1583
1882
  }
1584
- // evidence — lessons 키워드 (fail/롤백/incomplete) 동반
1883
+ // evidence — lessons 키워드 (fail/롤백/incomplete) 동반 (1.9.15: 라인 번호)
1585
1884
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1586
1885
  for (const block of ev.split(/\n(?=## )/)) {
1587
1886
  if (!block.startsWith('## ')) continue;
1588
- if (re.test(block)) {
1887
+ if (matches(block)) {
1589
1888
  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() });
1889
+ const idx = ev.indexOf(block);
1890
+ const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
1891
+ hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
1892
+ if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
1592
1893
  }
1593
1894
  }
1594
1895
 
1595
1896
  const total = hits.decisions.length + hits.skills.length + hits.tasks.length + hits.rules.length + hits.evidence.length;
1596
1897
  log(`\n📦 총 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length})`);
1597
1898
 
1899
+ // 1.9.15: 모든 출력에 출처 파일:라인 표시
1598
1900
  if (hits.decisions.length) {
1599
1901
  log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
1600
- hits.decisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
1902
+ hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${d.title}`));
1601
1903
  }
1602
1904
  if (hits.skills.length) {
1603
1905
  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}`));
1906
+ hits.skills.forEach(s => log(` - .harness/skills/${s.id}/skill.json — ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`));
1605
1907
  }
1606
1908
  if (hits.tasks.length) {
1607
1909
  log(`\n## 📌 관련 과거 task (${hits.tasks.length})`);
1608
- hits.tasks.slice(0, 5).forEach(t => log(` - ${t.id} [${t.status}] ${t.request}`));
1910
+ 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
1911
  }
1610
1912
  if (hits.rules.length) {
1611
1913
  log(`\n## ⚡ 관련 룰 (${hits.rules.length})`);
1612
- hits.rules.forEach(r => log(` - ${r.id} [${r.trigger}] ${r.rule}`));
1914
+ hits.rules.forEach(r => log(` - .harness/rules.md:${r.line || '?'} — ${r.id} [${r.trigger}] ${r.rule}`));
1613
1915
  }
1614
1916
  if (hits.evidence.length) {
1615
1917
  log(`\n## 🧪 관련 검증 기록 (${hits.evidence.length})`);
1616
- hits.evidence.slice(0, 5).forEach(e => log(` - ${e.title}`));
1918
+ hits.evidence.slice(0, 5).forEach(e => log(` - .harness/review-evidence.md:${e.line || '?'} — ${e.title}`));
1617
1919
  }
1618
1920
  if (hits.lessons.length) {
1619
1921
  log(`\n## ⚠ 같은 주제 과거 실패/롤백 (${hits.lessons.length}) — 같은 실수 방지`);
1620
- hits.lessons.slice(0, 5).forEach(l => log(` - ${l.title}`));
1922
+ hits.lessons.slice(0, 5).forEach(l => log(` - .harness/review-evidence.md:${l.line || '?'} — ${l.title}`));
1621
1923
  }
1622
1924
 
1623
1925
  log(`\n## 💡 시작 전 권장 액션`);
@@ -2434,9 +2736,8 @@ function lessonsCmd(root) {
2434
2736
  const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
2435
2737
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
2436
2738
  const lessons = [];
2437
- // decisions: ### 블록 전체
2438
- for (const block of decisions.split(/\n(?=### )/)) {
2439
- if (!block.startsWith('### ')) continue;
2739
+ // decisions: ### 블록 전체 (1.9.14: 코드블록/Template 제외)
2740
+ for (const block of _extractDecisionBlocks(decisions)) {
2440
2741
  const m = block.match(/^### (.+)$/m);
2441
2742
  if (!m) continue;
2442
2743
  lessons.push({ source: 'decisions.md', title: m[1].trim(), block });
@@ -2895,9 +3196,9 @@ function viewworkInstall(root) {
2895
3196
 
2896
3197
  function help() {
2897
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
2898
- leerness retro [path] [--days 7] # 회고 (1.9.13) — 작업/스킬/결정/검증 시간 추세 + 권장
2899
- leerness insights [path] # 누적 통계 (1.9.13) — 핵심 지표 + 안정성
2900
- leerness brainstorm "<주제>" # 브레인스토밍 (1.9.13) — 누적 자원 회수 + 시작 컨텍스트
3199
+ leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
3200
+ leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
3201
+ leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
2901
3202
  leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
2902
3203
  leerness roadmap auto on|off|status [--on-every-change] [--out file.html] # 자동 갱신 (1.9.12, install/session-close 기본 ON)
2903
3204
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.13",
3
+ "version": "1.9.16",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -237,6 +237,179 @@ total++;
237
237
  if (!(strongOK && weakHint)) failed++;
238
238
  }
239
239
 
240
+ // 1.9.16 회귀: brainstorm --all-apps / --json / session close 워크스페이스 안내
241
+ total++;
242
+ {
243
+ // brainstorm --all-apps
244
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bsa-'));
245
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bsb-'));
246
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
247
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
248
+ fs.appendFileSync(path.join(tmpA, '.harness/decisions.md'), '\n### 2026-05-13 — 캐시 정책\n- Reason: rate limit\n');
249
+ fs.appendFileSync(path.join(tmpB, '.harness/decisions.md'), '\n### 2026-05-13 — 캐시 분산\n- Reason: 확장성\n');
250
+ const r = cp.spawnSync(process.execPath, [CLI, 'brainstorm', '캐시', '--include', `${tmpA},${tmpB}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
251
+ const ok = r.status === 0 && /Cross-project Brainstorm — "캐시" — 2개/.test(r.stdout) && /워크스페이스 총합: 2건/.test(r.stdout);
252
+ console.log(ok ? '✓ B(1.9.16) brainstorm --include 통합' : '✗ brainstorm --include 실패');
253
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
254
+ }
255
+
256
+ total++;
257
+ {
258
+ // --json 단일 brainstorm
259
+ const tmpJ = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-json-'));
260
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpJ, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
261
+ fs.appendFileSync(path.join(tmpJ, '.harness/decisions.md'), '\n### 2026-05-13 — JSON 결정\n- ...\n');
262
+ const r = cp.spawnSync(process.execPath, [CLI, 'brainstorm', 'JSON', '--json', '--path', tmpJ], { encoding: 'utf8', timeout: 15000 });
263
+ let parsed = null;
264
+ try { parsed = JSON.parse(r.stdout); } catch {}
265
+ const ok = r.status === 0 && parsed && parsed.topic === 'JSON' && parsed.total >= 1;
266
+ console.log(ok ? '✓ B(1.9.16) brainstorm --json 단일' : `✗ brainstorm --json 실패\n${r.stdout.slice(0, 300)}`);
267
+ if (!ok) failed++;
268
+ }
269
+
270
+ total++;
271
+ {
272
+ // retro --json
273
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rj-'));
274
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
275
+ const r = cp.spawnSync(process.execPath, [CLI, 'retro', tmpR, '--json'], { encoding: 'utf8', timeout: 15000 });
276
+ let parsed = null;
277
+ try { parsed = JSON.parse(r.stdout); } catch {}
278
+ const ok = r.status === 0 && parsed && parsed.summary && parsed.data;
279
+ console.log(ok ? '✓ B(1.9.16) retro --json' : '✗ retro --json 실패');
280
+ if (!ok) failed++;
281
+ }
282
+
283
+ total++;
284
+ {
285
+ // insights --json (workspace)
286
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-iwsa-'));
287
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-iwsb-'));
288
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
289
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
290
+ const r = cp.spawnSync(process.execPath, [CLI, 'insights', '--include', `${tmpA},${tmpB}`, '--json'], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
291
+ let parsed = null;
292
+ try { parsed = JSON.parse(r.stdout); } catch {}
293
+ const ok = r.status === 0 && parsed && parsed.projectCount === 2 && Array.isArray(parsed.projects);
294
+ console.log(ok ? '✓ B(1.9.16) insights --include --json' : '✗ insights --json 실패');
295
+ if (!ok) failed++;
296
+ }
297
+
298
+ total++;
299
+ {
300
+ // session close 끝에 워크스페이스 안내 (다른 leerness 프로젝트 시뮬)
301
+ const wsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws-'));
302
+ fs.mkdirSync(path.join(wsRoot, '_apps'), { recursive: true });
303
+ const proj = path.join(wsRoot, 'main');
304
+ const other = path.join(wsRoot, '_apps', 'other');
305
+ cp.spawnSync(process.execPath, [CLI, 'init', proj, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
306
+ cp.spawnSync(process.execPath, [CLI, 'init', other, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
307
+ // _apps가 proj와 같은 부모 디렉토리에 있어야 감지
308
+ // 우리 케이스는 wsRoot/main과 wsRoot/_apps/other → main에서 ../_apps 검색하면 발견
309
+ const r = cp.spawnSync(process.execPath, [CLI, 'session', 'close', proj], { encoding: 'utf8', timeout: 15000 });
310
+ const ok = /워크스페이스에 \d+개 다른 leerness 프로젝트/.test(r.stdout);
311
+ console.log(ok ? '✓ B(1.9.16) session close 워크스페이스 안내' : '✗ session close 안내 실패');
312
+ if (!ok) { failed++; console.log(r.stdout.slice(-500)); }
313
+ }
314
+
315
+ // 1.9.15 회귀: brainstorm 라인번호 / --all-apps / --include
316
+ total++;
317
+ {
318
+ const tmpL = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-line-'));
319
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpL, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
320
+ fs.appendFileSync(path.join(tmpL, '.harness/decisions.md'), '\n### 2026-05-13 — 캐시 정책 결정\n- Reason: rate limit 회피\n');
321
+ cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '캐시 helper 구현', '--path', tmpL], { stdio: 'ignore', timeout: 10000 });
322
+ const r = cp.spawnSync(process.execPath, [CLI, 'brainstorm', '캐시', '--path', tmpL], { encoding: 'utf8', timeout: 15000 });
323
+ const ok = /\.harness\/decisions\.md:\d+/.test(r.stdout) && /\.harness\/progress-tracker\.md:\d+/.test(r.stdout) && /matched: request/.test(r.stdout);
324
+ console.log(ok ? '✓ B(1.9.15) brainstorm: 파일:라인 + 매치 필드 표시' : `✗ 1.9.15 brainstorm 위치 실패\n${r.stdout.slice(0,500)}`);
325
+ if (!ok) failed++;
326
+ }
327
+ total++;
328
+ {
329
+ // --include 다중 경로 통합
330
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-wsA-'));
331
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-wsB-'));
332
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
333
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
334
+ cp.spawnSync(process.execPath, [CLI, 'plan', 'add', 'A 작업', '--status', 'done', '--path', tmpA], { stdio: 'ignore', timeout: 10000 });
335
+ cp.spawnSync(process.execPath, [CLI, 'plan', 'add', 'B 작업', '--status', 'planned', '--path', tmpB], { stdio: 'ignore', timeout: 10000 });
336
+ const r = cp.spawnSync(process.execPath, [CLI, 'retro', '--include', `${tmpA},${tmpB}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
337
+ const ok = r.status === 0 && /Cross-project retro — 2개 프로젝트/.test(r.stdout) && /워크스페이스 총합/.test(r.stdout);
338
+ console.log(ok ? '✓ B(1.9.15) retro --include: 2개 통합' : '✗ 1.9.15 retro --include 실패');
339
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
340
+ }
341
+ total++;
342
+ {
343
+ // insights --include
344
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-iA-'));
345
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-iB-'));
346
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
347
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
348
+ const r = cp.spawnSync(process.execPath, [CLI, 'insights', '--include', `${tmpA},${tmpB}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
349
+ const ok = r.status === 0 && /Workspace Insights — 2개/.test(r.stdout) && /TOTAL/.test(r.stdout);
350
+ console.log(ok ? '✓ B(1.9.15) insights --include: 표 형식 통합' : '✗ 1.9.15 insights --include 실패');
351
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
352
+ }
353
+ total++;
354
+ {
355
+ // 잘못된 --include 경로 — warn 출력 + .harness 있는 것만 처리
356
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bad-'));
357
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
358
+ const bad = '/tmp/nonexistent-leerness-' + Date.now();
359
+ const r = cp.spawnSync(process.execPath, [CLI, 'retro', '--include', `${tmpA},${bad}`], { encoding: 'utf8', timeout: 15000, cwd: os.tmpdir() });
360
+ const ok = r.status === 0 && /--include 무시/.test(r.stdout) && /Cross-project retro — 1개/.test(r.stdout);
361
+ console.log(ok ? '✓ B(1.9.15) --include 잘못된 경로 graceful skip' : '✗ 1.9.15 bad path 처리 실패');
362
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
363
+ }
364
+
365
+ // 1.9.14 회귀: A(Template 제외) / B(word boundary) / C(planned 포함) / D(코드블록 템플릿)
366
+ total++;
367
+ {
368
+ // A: init 직후 decisions.md의 Template이 결정으로 카운트되지 않아야 함
369
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-A-'));
370
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
371
+ const r = cp.spawnSync(process.execPath, [CLI, 'insights', tmpA], { encoding: 'utf8', timeout: 15000 });
372
+ const ok = r.status === 0 && /누적 결정 \(decisions\.md\): 0건/.test(r.stdout);
373
+ console.log(ok ? '✓ B(1.9.14-A) Template 제외: 누적 결정 0건' : `✗ A 실패\n${r.stdout.slice(0, 500)}`);
374
+ if (!ok) failed++;
375
+ }
376
+ total++;
377
+ {
378
+ // B: brainstorm 토큰 매칭 — "API"는 매치, "AP"는 부분 매치라 안 잡힘
379
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-B-'));
380
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
381
+ fs.appendFileSync(path.join(tmpB, '.harness/decisions.md'), '\n### 2026-05-13 — API rate limit 정책\n- Reason: ...\n');
382
+ // "limit" 매치
383
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'brainstorm', 'limit', '--path', tmpB], { encoding: 'utf8', timeout: 15000 });
384
+ // "lim" 부분 매치 — 매치되면 안 됨
385
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'brainstorm', 'lim', '--path', tmpB], { encoding: 'utf8', timeout: 15000 });
386
+ const ok = /총 1건/.test(r1.stdout) && /총 0건/.test(r2.stdout);
387
+ console.log(ok ? '✓ B(1.9.14-B) brainstorm word boundary: limit 매치 / lim 부분매치 안 잡힘' : `✗ B 실패\n${r1.stdout.slice(0, 200)}\n${r2.stdout.slice(0, 200)}`);
388
+ if (!ok) failed++;
389
+ }
390
+ total++;
391
+ {
392
+ // C: retro 다음 우선 작업에 planned 포함
393
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-C-'));
394
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
395
+ // 모든 task 제거 후 planned만 추가
396
+ fs.writeFileSync(path.join(tmpC, '.harness/progress-tracker.md'), `# Progress Tracker\nStatus values: requested, planned, in-progress, waiting, on-hold, blocked, incomplete, done, dropped\n\n| ID | Status | Request | Evidence | Next Action | Updated |\n|---|---|---|---|---|---|\n| T-0001 | planned | 미래 작업 | plan:M-0001 | 시작 예정 | 2026-05-13 |\n`);
397
+ const r = cp.spawnSync(process.execPath, [CLI, 'retro', tmpC], { encoding: 'utf8', timeout: 15000 });
398
+ const ok = r.status === 0 && /T-0001 \[planned\]/.test(r.stdout) && !/없음 — 새 plan add 권장/.test(r.stdout);
399
+ console.log(ok ? '✓ B(1.9.14-C) retro 다음 우선 작업에 planned 포함' : '✗ C 실패');
400
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
401
+ }
402
+ total++;
403
+ {
404
+ // D: init decisions.md가 ```md 코드블록으로 감싸짐
405
+ const tmpD = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-D-'));
406
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpD, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
407
+ const dec = fs.readFileSync(path.join(tmpD, '.harness/decisions.md'), 'utf8');
408
+ const ok = /```md\n### \d{4}-\d{2}-\d{2} — Decision/.test(dec) && /^## Template/m.test(dec);
409
+ console.log(ok ? '✓ B(1.9.14-D) decisions.md template 코드블록 감싸짐' : '✗ D 실패');
410
+ if (!ok) { failed++; console.log(dec.slice(0, 400)); }
411
+ }
412
+
240
413
  // 1.9.13: retro / insights / brainstorm
241
414
  total++;
242
415
  {