leerness 1.9.11 → 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/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.11';
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];
@@ -198,7 +198,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = []) {
198
198
  const project = detectProjectName(root);
199
199
  const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
200
200
  return {
201
- 'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## Mandatory read order (session start)\n1. .harness/context-routing.md\n2. .harness/session-handoff.md\n3. .harness/current-state.md\n4. .harness/plan.md\n5. .harness/progress-tracker.md\n6. .harness/guideline.md\n7. .harness/protected-files.md\n8. .harness/writeback-policy.md\n9. .harness/anti-lazy-work-policy.md\n10. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- 작업 분류는 \`leerness route <task-type>\`로 확인합니다 (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- 보호 파일/관리 섹션을 삭제하지 않습니다. 머지·아카이브·deprecated 표시를 사용합니다.\n- 의미 있는 변경 후 progress-tracker, current-state, task-log, session-handoff를 갱신합니다.\n- 완료 선언 전 \`leerness check .\` 또는 \`leerness lazy detect .\`로 자기검증합니다.\n- 변경 전 secret/encoding 가드: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- 같은 기능 중복 생성 전 design-system.md, consistency-policy.md, reuse-map.md를 확인합니다.\n- 매 세션 종료 시 \`leerness session close .\`로 9개 카테고리(완료/진행중/미완료/예정/대기/보류/차단/드랍/검증) + **활성 룰 검증 결과**를 보고합니다.\n- 업데이트는 \`leerness update --check\` (감지) → \`leerness update --yes\` (자동 마이그레이션).\n\n## 자연어 룰 처리 (1.9.8)\n사용자가 자연어로 영구 룰을 요청하면 즉시 leerness rule 명령으로 등록합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "매 업데이트마다 버전 bump해줘" | \`leerness rule add "버전을 patch로 bump" --trigger every-update\` |\n| "매 커밋마다 패치노트 추가해줘" | \`leerness rule add "패치노트 추가" --trigger every-commit\` |\n| "세션 종료마다 배포해줘" | \`leerness rule add "배포 (release publish)" --trigger session-close\` |\n| "X 룰 중지/그만/끄기" | \`leerness rule pause <ID>\` (해당 룰 ID는 list로 확인) |\n| "X 룰 제거/삭제" | \`leerness rule remove <ID>\` |\n| "모든 룰 중지" | \`leerness rule stop\` |\n| "룰 다시 켜줘" | \`leerness rule resume-all\` 또는 \`leerness rule resume <ID>\` |\n\n룰을 등록한 후 사용자에게 등록 결과(ID + trigger + 설명)를 보고하고, 그 이후 매 세션마다 자동 적용합니다. 사용자가 "중지" 또는 "제거"를 명시적으로 말하기 전까지는 룰을 비활성화하지 않습니다.\n\n## 룰 자동 적용 (1.9.8)\nleerness가 자동 검증 가능한 trigger:\n- **every-update / version bump 키워드 룰**: package.json의 version이 갱신됐는지 검사 (handoff/session close가 baseline 캐시와 비교).\n- **CHANGELOG / 패치노트 키워드 룰**: CHANGELOG.md의 mtime이 갱신됐는지 검사.\n- **test / 테스트 / verify 키워드 룰**: review-evidence.md에 오늘 verify-code 흔적이 있는지 검사.\n- **배포 / publish / push 키워드 룰**: 자동 검증 불가 → 사용자에게 release publish 명령을 안내.\n\n자동 검증 가능한 룰의 실행은 \`leerness release bump\`, \`leerness release note "..."\`, \`leerness release publish\`를 사용해 자동화합니다.\n`,
201
+ 'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## Mandatory read order (session start)\n1. .harness/context-routing.md\n2. .harness/session-handoff.md\n3. .harness/current-state.md\n4. .harness/plan.md\n5. .harness/progress-tracker.md\n6. .harness/guideline.md\n7. .harness/protected-files.md\n8. .harness/writeback-policy.md\n9. .harness/anti-lazy-work-policy.md\n10. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- 작업 분류는 \`leerness route <task-type>\`로 확인합니다 (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- 보호 파일/관리 섹션을 삭제하지 않습니다. 머지·아카이브·deprecated 표시를 사용합니다.\n- 의미 있는 변경 후 progress-tracker, current-state, task-log, session-handoff를 갱신합니다.\n- 완료 선언 전 \`leerness check .\` 또는 \`leerness lazy detect .\`로 자기검증합니다.\n- 변경 전 secret/encoding 가드: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- 같은 기능 중복 생성 전 design-system.md, consistency-policy.md, reuse-map.md를 확인합니다.\n- 매 세션 종료 시 \`leerness session close .\`로 9개 카테고리(완료/진행중/미완료/예정/대기/보류/차단/드랍/검증) + **활성 룰 검증 결과**를 보고합니다.\n- 업데이트는 \`leerness update --check\` (감지) → \`leerness update --yes\` (자동 마이그레이션).\n\n## 자연어 회고/통찰/브레인스토밍 (1.9.13)\n사용자가 자연어로 회고/통찰/브레인스토밍을 요청하면 즉시 leerness 명령으로 호출합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "회고해줘 / 돌아보자 / 정리해줘" | \`leerness retro\` |\n| "최근 N일 회고" | \`leerness retro --days N\` |\n| "통계 / 누적 지표 / insights" | \`leerness insights\` |\n| "X에 대해 브레인스토밍 / X 관련 자료 / X 시작 전 검토" | \`leerness brainstorm "X"\` |\n\nsession close가 매번 자동으로 한 줄 요약을 출력하고, 5세션마다 자동 깊은 회고를 실행합니다. 사용자가 명시 요청 시 즉시 호출.\n\n## 자연어 룰 처리 (1.9.8)\n사용자가 자연어로 영구 룰을 요청하면 즉시 leerness rule 명령으로 등록합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "매 업데이트마다 버전 bump해줘" | \`leerness rule add "버전을 patch로 bump" --trigger every-update\` |\n| "매 커밋마다 패치노트 추가해줘" | \`leerness rule add "패치노트 추가" --trigger every-commit\` |\n| "세션 종료마다 배포해줘" | \`leerness rule add "배포 (release publish)" --trigger session-close\` |\n| "X 룰 중지/그만/끄기" | \`leerness rule pause <ID>\` (해당 룰 ID는 list로 확인) |\n| "X 룰 제거/삭제" | \`leerness rule remove <ID>\` |\n| "모든 룰 중지" | \`leerness rule stop\` |\n| "룰 다시 켜줘" | \`leerness rule resume-all\` 또는 \`leerness rule resume <ID>\` |\n\n룰을 등록한 후 사용자에게 등록 결과(ID + trigger + 설명)를 보고하고, 그 이후 매 세션마다 자동 적용합니다. 사용자가 "중지" 또는 "제거"를 명시적으로 말하기 전까지는 룰을 비활성화하지 않습니다.\n\n## 룰 자동 적용 (1.9.8)\nleerness가 자동 검증 가능한 trigger:\n- **every-update / version bump 키워드 룰**: package.json의 version이 갱신됐는지 검사 (handoff/session close가 baseline 캐시와 비교).\n- **CHANGELOG / 패치노트 키워드 룰**: CHANGELOG.md의 mtime이 갱신됐는지 검사.\n- **test / 테스트 / verify 키워드 룰**: review-evidence.md에 오늘 verify-code 흔적이 있는지 검사.\n- **배포 / publish / push 키워드 룰**: 자동 검증 불가 → 사용자에게 release publish 명령을 안내.\n\n자동 검증 가능한 룰의 실행은 \`leerness release bump\`, \`leerness release note "..."\`, \`leerness release publish\`를 사용해 자동화합니다.\n`,
202
202
  'CLAUDE.md': `${MARK}\n# Claude Code Instructions\n\nFollow AGENTS.md. Always run \`leerness handoff .\` at the start and \`leerness session close .\` before ending a session.\n\nProtected files must not be deleted. Read .harness/anti-lazy-work-policy.md before claiming completion.\n\n## 자연어 영구 룰 (1.9.8)\n사용자가 "매 X마다 Y를 해줘" 같은 자연어 룰을 말하면 즉시 \`leerness rule add "Y" --trigger every-X\`로 등록하세요. 등록된 룰은 매 세션 \`handoff\`가 자동 출력하고, \`session close\`가 자동 검증해 보고합니다. 사용자가 "중지" / "그만" / "끄기"를 명시할 때만 \`rule pause/remove\`를 호출합니다.\n\n자세한 매핑은 AGENTS.md의 "자연어 룰 처리" 표를 참고하세요.\n`,
203
203
  '.cursor/rules/leerness.mdc': `${MARK}\n---\nalwaysApply: true\n---\nFollow AGENTS.md and .harness/context-routing.md.\nRun: \`leerness handoff .\` at session start.\nRun: \`leerness session close .\` at session end.\nPreserve Leerness protected files.\n`,
204
204
  '.github/copilot-instructions.md': `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .harness/ as project memory.\nDo not remove protected Leerness files.\nBefore completion, ensure plan.md, progress-tracker.md, current-state.md, session-handoff.md are updated.\n`,
@@ -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`),
@@ -458,6 +458,10 @@ async function install(root, opts = {}) {
458
458
  if (!has('--no-auto-update')) {
459
459
  try { autoUpdateInstall(root); } catch (e) { warn('auto-update hook install skipped: ' + (e && e.message)); }
460
460
  }
461
+ // 1.9.12: install 직후 첫 roadmap.html 자동 생성
462
+ if (!has('--no-auto-roadmap')) {
463
+ try { _autoRoadmap(root, 'install'); } catch (e) { warn('auto-roadmap 실패: ' + (e && e.message)); }
464
+ }
461
465
  }
462
466
  }
463
467
 
@@ -689,6 +693,7 @@ function planAdd(root, text) {
689
693
  const tid = nextId(root, 'T');
690
694
  upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction: arg('--next', '다음 액션 작성') });
691
695
  ok(`plan added: ${id} → progress: ${tid}`);
696
+ _autoRoadmap(absRoot(root), 'data-change');
692
697
  }
693
698
  function planDrop(root, text) {
694
699
  const id = nextId(root, 'D');
@@ -725,6 +730,7 @@ function taskAdd(root, text) {
725
730
  const id = nextId(root, 'T');
726
731
  upsertProgress(root, { id, status: arg('--status','requested'), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
727
732
  ok(`task added: ${id}`);
733
+ _autoRoadmap(absRoot(root), 'data-change');
728
734
  }
729
735
  function taskUpdate(root, id) {
730
736
  if (!id) return fail('id required (e.g., task update T-0001 --status in-progress)');
@@ -737,11 +743,13 @@ function taskUpdate(root, id) {
737
743
  if (arg('--note')) patch.request = arg('--note');
738
744
  upsertProgress(root, patch);
739
745
  ok(`task updated: ${id}`);
746
+ _autoRoadmap(absRoot(root), 'data-change');
740
747
  }
741
748
  function taskDrop(root, id) {
742
749
  if (!id) return fail('id required');
743
750
  upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
744
751
  ok(`task dropped: ${id}`);
752
+ _autoRoadmap(absRoot(root), 'data-change');
745
753
  }
746
754
 
747
755
  // 1.9.6: 옛 link 손실 row를 plan.md milestone과 자동 매칭 제안/복구.
@@ -1269,6 +1277,45 @@ function sessionClose(root) {
1269
1277
  log('\n## Required final response sections');
1270
1278
  log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과');
1271
1279
  ok(`session-handoff.md and current-state.md updated`);
1280
+ // 1.9.12: session close 끝에 roadmap.html 자동 갱신
1281
+ _autoRoadmap(root, 'session-close');
1282
+ // 1.9.13: 세션 카운터 + 자동 한 줄 요약 + 5세션마다 깊은 회고
1283
+ try {
1284
+ const sc = readSessionCounter(root);
1285
+ sc.count = (sc.count || 0) + 1;
1286
+ sc.lastCloseAt = now();
1287
+ writeSessionCounter(root, sc);
1288
+ const agg = _retroAggregate(root);
1289
+ log(`\n## 📈 진행 요약 (session #${sc.count})`);
1290
+ log(` ${_retroOneLine(agg)}`);
1291
+ if (sc.count % 5 === 0) {
1292
+ log(`\n## 🔄 ${sc.count}세션 마일스톤 — 자동 회고 (5세션마다)`);
1293
+ retroCmd(root);
1294
+ sc.lastDeepRetroAt = now();
1295
+ writeSessionCounter(root, sc);
1296
+ } else {
1297
+ const left = 5 - (sc.count % 5);
1298
+ log(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`);
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 {}
1316
+ } catch (e) {
1317
+ warn('retro 요약 실패: ' + (e && e.message ? e.message : e));
1318
+ }
1272
1319
  }
1273
1320
 
1274
1321
  function readmeCmd(root) { syncReadme(absRoot(root)); }
@@ -1317,6 +1364,571 @@ function gate(root) {
1317
1364
  else ok('all gates passed');
1318
1365
  }
1319
1366
 
1367
+ // ===== 1.9.13: Retrospective / Insights / Brainstorming =====
1368
+ function sessionCounterPath(root) { return path.join(root, '.harness/cache/session-counter.json'); }
1369
+ function readSessionCounter(root) {
1370
+ if (!exists(sessionCounterPath(root))) return { count: 0, lastCloseAt: null, lastDeepRetroAt: null };
1371
+ try { return JSON.parse(read(sessionCounterPath(root))); } catch { return { count: 0, lastCloseAt: null, lastDeepRetroAt: null }; }
1372
+ }
1373
+ function writeSessionCounter(root, c) { writeUtf8(sessionCounterPath(root), JSON.stringify(c, null, 2) + '\n'); }
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
+
1384
+ function _retroAggregate(root) {
1385
+ root = absRoot(root);
1386
+ const rows = readProgressRows(root);
1387
+ const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1388
+ const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
1389
+ const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1390
+ const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
1391
+
1392
+ // 1) 작업 상태 분포
1393
+ const statusCounts = {};
1394
+ for (const s of STATUSES) statusCounts[s] = 0;
1395
+ for (const r of rows) if (statusCounts[r.status] != null) statusCounts[r.status]++;
1396
+
1397
+ // 2) 결정 블록 수 (1.9.14: 코드블록/Template 제외)
1398
+ const decisionBlocks = _extractDecisionBlocks(decisions);
1399
+ // recent decisions (날짜로 정렬 시 가장 최근)
1400
+ const recentDecisions = decisionBlocks.slice(-5).map(b => {
1401
+ const t = (b.match(/^### (.+)$/m) || [, ''])[1];
1402
+ return { title: t.trim(), block: b.slice(0, 200) };
1403
+ }).reverse();
1404
+
1405
+ // 3) 스킬 활용
1406
+ const skillsDir = path.join(root, '.harness/skills');
1407
+ const skillUsage = [];
1408
+ if (exists(skillsDir)) {
1409
+ for (const id of fs.readdirSync(skillsDir)) {
1410
+ const f = path.join(skillsDir, id, 'skill.json');
1411
+ if (!exists(f)) continue;
1412
+ try {
1413
+ const s = JSON.parse(read(f));
1414
+ skillUsage.push({
1415
+ id,
1416
+ displayNameKo: s.displayNameKo || id,
1417
+ count: s.usage?.count || 0,
1418
+ lastUsed: s.usage?.lastUsed || null,
1419
+ optimizations: (s.optimizations || []).length,
1420
+ capabilities: (s.capabilities || []).length
1421
+ });
1422
+ } catch {}
1423
+ }
1424
+ }
1425
+ skillUsage.sort((a, b) => (b.count - a.count) || (b.optimizations - a.optimizations));
1426
+
1427
+ // 4) 검증 시간 추세 — review-evidence.md에서 "exit=0 (Nms)" 또는 "(Nms)" 패턴
1428
+ const durations = [];
1429
+ for (const m of evidence.matchAll(/exit=\d+\s*\((\d+)ms\)/g)) durations.push(parseInt(m[1], 10));
1430
+
1431
+ // 5) 실패→성공 시그널 — task-log/evidence/decisions에서 "롤백" / "fail" / "재발" / "fix" / "수정" 등의 동시 등장 카운트
1432
+ const fixSignals = (tlog + evidence + decisions).match(/\b(fix|fixed|수정|롤백|재발|incomplete|bug)\b/gi) || [];
1433
+ const passSignals = (tlog + evidence + decisions).match(/(?:✓|pass(?:ed)?|통과|completed|done)/gi) || [];
1434
+
1435
+ // 6) 룰 활용
1436
+ const rules = exists(rulesPath(root)) ? readRules(root) : [];
1437
+ const activeRules = rules.filter(r => r.status === 'active');
1438
+ const verifiedRules = rules.filter(r => r.lastVerified && r.lastVerified !== '-');
1439
+
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));
1444
+
1445
+ return {
1446
+ statusCounts,
1447
+ rows,
1448
+ totalTasks: rows.length,
1449
+ doneCount: statusCounts.done,
1450
+ decisionBlocks: decisionBlocks.length,
1451
+ recentDecisions,
1452
+ skillUsage,
1453
+ totalSkillUsage: skillUsage.reduce((a, b) => a + b.count, 0),
1454
+ totalOptimizations: skillUsage.reduce((a, b) => a + b.optimizations, 0),
1455
+ durations,
1456
+ fixSignals: fixSignals.length,
1457
+ passSignals: passSignals.length,
1458
+ activeRules: activeRules.length,
1459
+ verifiedRules: verifiedRules.length,
1460
+ focusNext
1461
+ };
1462
+ }
1463
+
1464
+ function _retroOneLine(agg) {
1465
+ const parts = [];
1466
+ const done = agg.statusCounts.done;
1467
+ const total = agg.totalTasks;
1468
+ if (total) parts.push(`완료 ${done}/${total} (${Math.round(done / total * 100)}%)`);
1469
+ if (agg.totalSkillUsage) parts.push(`스킬 ${agg.skillUsage.length}종 / 사용 ${agg.totalSkillUsage}회 / 최적화 ${agg.totalOptimizations}건`);
1470
+ if (agg.activeRules) parts.push(`룰 ${agg.activeRules}건 활성 (${agg.verifiedRules} 검증됨)`);
1471
+ if (agg.durations.length >= 4) {
1472
+ const mid = Math.floor(agg.durations.length / 2);
1473
+ const a = agg.durations.slice(0, mid).reduce((x, y) => x + y, 0) / mid;
1474
+ const b = agg.durations.slice(mid).reduce((x, y) => x + y, 0) / (agg.durations.length - mid);
1475
+ if (a > 0) {
1476
+ const delta = ((b - a) / a) * 100;
1477
+ const sign = delta > 0 ? '+' : '';
1478
+ parts.push(`검증 ${Math.round(a)}ms→${Math.round(b)}ms (${sign}${delta.toFixed(1)}%)`);
1479
+ }
1480
+ }
1481
+ parts.push(`결정 ${agg.decisionBlocks}건 누적`);
1482
+ return parts.join(' · ');
1483
+ }
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
+
1514
+ function retroCmd(root) {
1515
+ root = absRoot(root);
1516
+ // 1.9.15: --all-apps / --include 통합 모드
1517
+ if (has('--all-apps') || arg('--include', null)) {
1518
+ return _retroWorkspace(root);
1519
+ }
1520
+ const days = parseInt(arg('--days', '7'), 10);
1521
+ const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10);
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
+ }
1528
+ log(`# 회고 (retro) — 최근 ${days}일 (since ${cutoff})`);
1529
+ log(`\n📈 한 줄 요약: ${_retroOneLine(agg)}`);
1530
+
1531
+ log(`\n## 작업 상태 분포`);
1532
+ for (const s of STATUSES) if (agg.statusCounts[s]) log(` - ${s}: ${agg.statusCounts[s]}`);
1533
+
1534
+ log(`\n## 🎯 다음 우선 작업 (top 5)`);
1535
+ if (!agg.focusNext.length) log(' (없음 — 새 plan add 권장)');
1536
+ else agg.focusNext.slice(0, 5).forEach(r => log(` - ${r.id} [${r.status}] ${r.request} → ${r.nextAction}`));
1537
+
1538
+ log(`\n## 📚 스킬 활용 추세 (top 5)`);
1539
+ if (!agg.skillUsage.length) log(' (등록된 스킬 없음)');
1540
+ else agg.skillUsage.slice(0, 5).forEach(s => log(` - ${s.id}: 사용 ${s.count}회, 최적화 ${s.optimizations}건, capabilities ${s.capabilities}개${s.lastUsed ? ' · 마지막 ' + s.lastUsed.slice(0, 10) : ''}`));
1541
+
1542
+ log(`\n## 🧠 최근 결정 (top 5)`);
1543
+ if (!agg.recentDecisions.length) log(' (없음)');
1544
+ else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
1545
+
1546
+ if (agg.durations.length >= 4) {
1547
+ const mid = Math.floor(agg.durations.length / 2);
1548
+ const a = agg.durations.slice(0, mid).reduce((x, y) => x + y, 0) / mid;
1549
+ const b = agg.durations.slice(mid).reduce((x, y) => x + y, 0) / (agg.durations.length - mid);
1550
+ const delta = ((b - a) / a) * 100;
1551
+ log(`\n## ⏱ 검증 시간 추세 (review-evidence)`);
1552
+ log(` - 전반부 평균: ${Math.round(a)}ms`);
1553
+ log(` - 후반부 평균: ${Math.round(b)}ms`);
1554
+ log(` - 변화: ${delta > 0 ? '+' : ''}${delta.toFixed(1)}% ${delta < 0 ? '🚀 빨라짐' : delta > 10 ? '⚠ 느려짐' : ''}`);
1555
+ }
1556
+
1557
+ log(`\n## ⚡ 활성 룰 / 검증 비율`);
1558
+ log(` - 활성 ${agg.activeRules}건 · 검증됨 ${agg.verifiedRules}건 (${agg.activeRules ? Math.round(agg.verifiedRules / agg.activeRules * 100) : 0}%)`);
1559
+
1560
+ log(`\n## 🔁 fix/pass 시그널`);
1561
+ log(` - fix 시그널 (롤백/수정/bug/incomplete): ${agg.fixSignals}회`);
1562
+ log(` - pass 시그널 (통과/✓/completed): ${agg.passSignals}회`);
1563
+ if (agg.passSignals > agg.fixSignals * 2) log(' - 평가: 안정적 (pass >> fix)');
1564
+ else if (agg.fixSignals > agg.passSignals) log(' - 평가: 디버그 비중 높음 — verify-code 자동화 검토');
1565
+
1566
+ log(`\n## 💡 권장 다음 단계`);
1567
+ if (agg.focusNext.length) log(` 1. ${agg.focusNext[0].id} (${agg.focusNext[0].status}): ${agg.focusNext[0].nextAction}`);
1568
+ if (agg.skillUsage.length && agg.skillUsage[0].count > 0) log(` 2. 가장 활발한 스킬 "${agg.skillUsage[0].id}"의 패턴을 다른 작업에 재사용 가능`);
1569
+ if (agg.totalOptimizations > 0) log(` 3. 누적된 최적화 ${agg.totalOptimizations}건을 새 작업의 시작 전 참고 (\`leerness skill info <id>\`)`);
1570
+ log(` 4. \`leerness brainstorm <주제>\`로 누적 데이터 기반 컨텍스트 적재`);
1571
+ }
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
+
1620
+ function insightsCmd(root) {
1621
+ root = absRoot(root);
1622
+ // 1.9.15: --all-apps / --include 통합 모드
1623
+ if (has('--all-apps') || arg('--include', null)) {
1624
+ return _insightsWorkspace(root);
1625
+ }
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
+ }
1633
+ const sc = readSessionCounter(root);
1634
+ log(`# Insights — 누적 통계`);
1635
+ log(`\n## 📊 핵심 지표`);
1636
+ log(` - 누적 task: ${agg.totalTasks} (done ${agg.doneCount}, in-progress ${agg.statusCounts['in-progress']}, planned ${agg.statusCounts.planned})`);
1637
+ log(` - 누적 결정 (decisions.md): ${agg.decisionBlocks}건`);
1638
+ log(` - 누적 스킬: ${agg.skillUsage.length}종`);
1639
+ log(` - 총 스킬 사용: ${agg.totalSkillUsage}회`);
1640
+ log(` - 총 최적화 누적: ${agg.totalOptimizations}건`);
1641
+ log(` - 활성 룰: ${agg.activeRules}건 (검증 ${agg.verifiedRules}건)`);
1642
+ log(` - session close 횟수: ${sc.count}회${sc.lastCloseAt ? ' (마지막: ' + sc.lastCloseAt.slice(0, 16) + ')' : ''}`);
1643
+
1644
+ if (agg.skillUsage.length) {
1645
+ log(`\n## 🏆 가장 활용도 높은 스킬 (top 5)`);
1646
+ agg.skillUsage.slice(0, 5).forEach((s, i) => log(` ${i + 1}. ${s.id} (${s.displayNameKo}) — 사용 ${s.count}회, 최적화 ${s.optimizations}건`));
1647
+ }
1648
+
1649
+ if (agg.durations.length) {
1650
+ const total = agg.durations.reduce((a, b) => a + b, 0);
1651
+ log(`\n## ⏱ 검증 시간 (verify-code)`);
1652
+ log(` - 실행: ${agg.durations.length}회 / 총 ${total}ms / 평균 ${Math.round(total / agg.durations.length)}ms`);
1653
+ log(` - 최소 ${Math.min(...agg.durations)}ms / 최대 ${Math.max(...agg.durations)}ms`);
1654
+ }
1655
+
1656
+ log(`\n## 🔁 안정성 지표`);
1657
+ log(` - pass 시그널: ${agg.passSignals} · fix 시그널: ${agg.fixSignals}`);
1658
+ const ratio = agg.fixSignals > 0 ? (agg.passSignals / agg.fixSignals).toFixed(2) : '∞';
1659
+ log(` - pass/fix 비율: ${ratio}${ratio === '∞' || parseFloat(ratio) > 3 ? ' (안정)' : parseFloat(ratio) < 1 ? ' (디버그 위주)' : ' (보통)'}`);
1660
+
1661
+ log(`\n## 📈 권장`);
1662
+ if (agg.totalOptimizations === 0) log(` - 스킬에 최적화 누적 없음 — \`leerness skill optimize <id> --before --after\`로 더 나은 방법 기록`);
1663
+ if (sc.count >= 5 && sc.count % 5 === 0) log(` - 5세션마다 자동 깊은 회고가 예정되어 있습니다 — session close가 자동 호출`);
1664
+ if (agg.statusCounts.blocked > 0) log(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`);
1665
+ }
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
+
1811
+ function brainstormCmd(root, topic) {
1812
+ root = absRoot(root);
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
+ }
1824
+ log(`# Brainstorm — "${topic}"`);
1825
+ log(`\n누적된 leerness 데이터에서 주제 관련 자원을 회수합니다.`);
1826
+
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)); }
1832
+ const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
1833
+
1834
+ // decisions (1.9.14: 코드블록/Template 제외, 1.9.15: 라인 번호)
1835
+ const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1836
+ const decLines = dec.split('\n');
1837
+ for (const b of _extractDecisionBlocks(dec)) {
1838
+ if (matches(b)) {
1839
+ const t = (b.match(/^### (.+)$/m) || [, ''])[1];
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 });
1843
+ }
1844
+ }
1845
+ // skills
1846
+ const skillsDir = path.join(root, '.harness/skills');
1847
+ if (exists(skillsDir)) {
1848
+ for (const id of fs.readdirSync(skillsDir)) {
1849
+ const f = path.join(skillsDir, id, 'skill.json');
1850
+ if (!exists(f)) continue;
1851
+ try {
1852
+ const s = JSON.parse(read(f));
1853
+ const text = JSON.stringify(s);
1854
+ if (matches(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
1855
+ } catch {}
1856
+ }
1857
+ }
1858
+ // tasks (1.9.14: token 매칭, 1.9.15: 매치 필드 + 라인 번호)
1859
+ const rows = readProgressRows(root);
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: 라인 번호)
1873
+ if (exists(rulesPath(root))) {
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
+ }
1882
+ }
1883
+ // evidence — lessons 키워드 (fail/롤백/incomplete) 동반 (1.9.15: 라인 번호)
1884
+ const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1885
+ for (const block of ev.split(/\n(?=## )/)) {
1886
+ if (!block.startsWith('## ')) continue;
1887
+ if (matches(block)) {
1888
+ const t = (block.match(/^## (.+)$/m) || [, ''])[1];
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 });
1893
+ }
1894
+ }
1895
+
1896
+ const total = hits.decisions.length + hits.skills.length + hits.tasks.length + hits.rules.length + hits.evidence.length;
1897
+ log(`\n📦 총 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length})`);
1898
+
1899
+ // 1.9.15: 모든 출력에 출처 파일:라인 표시
1900
+ if (hits.decisions.length) {
1901
+ log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
1902
+ hits.decisions.slice(0, 5).forEach(d => log(` - .harness/decisions.md:${d.line || '?'} — ${d.title}`));
1903
+ }
1904
+ if (hits.skills.length) {
1905
+ log(`\n## 📚 관련 스킬 (${hits.skills.length}) — 시작 전 \`skill info <id>\` 권장`);
1906
+ hits.skills.forEach(s => log(` - .harness/skills/${s.id}/skill.json — ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`));
1907
+ }
1908
+ if (hits.tasks.length) {
1909
+ log(`\n## 📌 관련 과거 task (${hits.tasks.length})`);
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('+')})`));
1911
+ }
1912
+ if (hits.rules.length) {
1913
+ log(`\n## ⚡ 관련 룰 (${hits.rules.length})`);
1914
+ hits.rules.forEach(r => log(` - .harness/rules.md:${r.line || '?'} — ${r.id} [${r.trigger}] ${r.rule}`));
1915
+ }
1916
+ if (hits.evidence.length) {
1917
+ log(`\n## 🧪 관련 검증 기록 (${hits.evidence.length})`);
1918
+ hits.evidence.slice(0, 5).forEach(e => log(` - .harness/review-evidence.md:${e.line || '?'} — ${e.title}`));
1919
+ }
1920
+ if (hits.lessons.length) {
1921
+ log(`\n## ⚠ 같은 주제 과거 실패/롤백 (${hits.lessons.length}) — 같은 실수 방지`);
1922
+ hits.lessons.slice(0, 5).forEach(l => log(` - .harness/review-evidence.md:${l.line || '?'} — ${l.title}`));
1923
+ }
1924
+
1925
+ log(`\n## 💡 시작 전 권장 액션`);
1926
+ log(` 1. 위 자원을 모두 검토 후 plan add 또는 task add로 새 작업 등록`);
1927
+ log(` 2. 가장 비슷한 과거 스킬을 \`leerness skill use <id>\`로 활성화`);
1928
+ log(` 3. 작업 종료 시 새로 발견한 패턴을 \`skill optimize\`로 누적`);
1929
+ if (!total) log(` ⓘ 관련 자원 없음 — 새로운 영역. 첫 결정/스킬을 기록하면 다음 brainstorm이 더 풍부해짐.`);
1930
+ }
1931
+
1320
1932
  // ===== 1.9.11: Roadmap (project-roadmap-generator 통합) =====
1321
1933
  const ROADMAP_STATUS_LABEL = { done: '완료', 'in-progress': '진행', 'on-hold': '보류', waiting: '검토', incomplete: '미완료', planned: '예정', blocked: '오류', dropped: '취소', skill: '스킬', rule: '룰', meta: '프로젝트' };
1322
1934
  const ROADMAP_STATUS_COLOR = { done: '#16a34a', 'in-progress': '#2563eb', 'on-hold': '#6b7280', waiting: '#eab308', incomplete: '#f97316', planned: '#94a3b8', blocked: '#dc2626', dropped: '#9ca3af', skill: '#8b5cf6', rule: '#06b6d4', meta: '#0f172a' };
@@ -1605,6 +2217,62 @@ function roadmapCmd(root) {
1605
2217
  log(` milestones: ${data.milestones.length} · tasks: ${data.tasks.length} (done ${data.tasks.filter(t => t.status === 'done').length}) · skills: ${data.skills.length} · active rules: ${data.rules.filter(r => r.status === 'active').length} · tokens: ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}`);
1606
2218
  }
1607
2219
 
2220
+ // 1.9.12: auto roadmap (install / session-close / 옵트인 data-change 트리거)
2221
+ function _autoRoadmapConfigPath(root) { return path.join(root, '.harness/cache/auto-roadmap.json'); }
2222
+ function _autoRoadmapConfig(root) {
2223
+ const f = _autoRoadmapConfigPath(root);
2224
+ const def = { enabled: true, onEveryChange: false, outFile: null };
2225
+ if (!exists(f)) return def;
2226
+ try { return Object.assign(def, JSON.parse(read(f))); } catch { return def; }
2227
+ }
2228
+ function _saveAutoRoadmapConfig(root, cfg) {
2229
+ writeUtf8(_autoRoadmapConfigPath(root), JSON.stringify(cfg, null, 2) + '\n');
2230
+ }
2231
+ function _autoRoadmap(root, trigger) {
2232
+ try {
2233
+ if (process.env.LEERNESS_NO_AUTO_ROADMAP === '1') return false;
2234
+ if (!exists(path.join(root, '.harness'))) return false;
2235
+ const cfg = _autoRoadmapConfig(root);
2236
+ if (!cfg.enabled) return false;
2237
+ if (trigger === 'data-change' && !cfg.onEveryChange) return false;
2238
+ const outFile = path.resolve(cfg.outFile || path.join(root, 'roadmap.html'));
2239
+ const data = _roadmapData(root);
2240
+ writeUtf8(outFile, _roadmapHTML(data));
2241
+ log(`✓ roadmap.html 자동 갱신 (${trigger}) — ${rel(root, outFile)}`);
2242
+ return true;
2243
+ } catch (e) {
2244
+ warn('roadmap 자동 갱신 실패: ' + (e && e.message ? e.message : e));
2245
+ return false;
2246
+ }
2247
+ }
2248
+
2249
+ function roadmapAutoCmd(root, sub) {
2250
+ root = absRoot(root);
2251
+ if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음`);
2252
+ const cfg = _autoRoadmapConfig(root);
2253
+ if (sub === 'on') {
2254
+ cfg.enabled = true;
2255
+ if (has('--on-every-change')) cfg.onEveryChange = true;
2256
+ if (has('--no-on-every-change')) cfg.onEveryChange = false;
2257
+ if (arg('--out', null)) cfg.outFile = arg('--out', null);
2258
+ _saveAutoRoadmapConfig(root, cfg);
2259
+ ok(`auto-roadmap 활성화 (onEveryChange: ${cfg.onEveryChange}, outFile: ${cfg.outFile || './roadmap.html'})`);
2260
+ } else if (sub === 'off') {
2261
+ cfg.enabled = false;
2262
+ _saveAutoRoadmapConfig(root, cfg);
2263
+ ok('auto-roadmap 비활성화 — session close 시 갱신 안 됨');
2264
+ } else {
2265
+ log(`# auto-roadmap status`);
2266
+ log(`enabled: ${cfg.enabled}`);
2267
+ log(`onEveryChange: ${cfg.onEveryChange}`);
2268
+ log(`outFile: ${cfg.outFile || './roadmap.html'}`);
2269
+ log(`\n트리거:`);
2270
+ log(` install : ${cfg.enabled ? '✓ 자동 생성' : '✗ 비활성'}`);
2271
+ log(` session-close: ${cfg.enabled ? '✓ 자동 갱신' : '✗ 비활성'}`);
2272
+ log(` data-change : ${cfg.enabled && cfg.onEveryChange ? '✓ 즉시 갱신 (모든 task/plan/rule/skill 변경)' : '✗ 옵트인 필요 (--on-every-change)'}`);
2273
+ }
2274
+ }
2275
+
1608
2276
  // ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
1609
2277
  function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
1610
2278
  function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
@@ -1683,6 +2351,7 @@ function ruleAdd(root, description) {
1683
2351
  rules.push({ id, trigger, rule: description, added: today(), status: 'active', lastVerified: '-' });
1684
2352
  writeRules(root, rules);
1685
2353
  ok(`rule added: ${id} [${trigger}] ${description}`);
2354
+ _autoRoadmap(root, 'data-change');
1686
2355
  }
1687
2356
 
1688
2357
  function ruleList(root) {
@@ -1716,6 +2385,7 @@ function rulePause(root, id) {
1716
2385
  r.status = 'paused';
1717
2386
  writeRules(root, rules);
1718
2387
  ok(`rule paused: ${id}`);
2388
+ _autoRoadmap(root, 'data-change');
1719
2389
  }
1720
2390
 
1721
2391
  function ruleResume(root, id) {
@@ -1727,6 +2397,7 @@ function ruleResume(root, id) {
1727
2397
  r.status = 'active';
1728
2398
  writeRules(root, rules);
1729
2399
  ok(`rule resumed: ${id}`);
2400
+ _autoRoadmap(root, 'data-change');
1730
2401
  }
1731
2402
 
1732
2403
  function ruleStop(root) {
@@ -2065,9 +2736,8 @@ function lessonsCmd(root) {
2065
2736
  const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
2066
2737
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
2067
2738
  const lessons = [];
2068
- // decisions: ### 블록 전체
2069
- for (const block of decisions.split(/\n(?=### )/)) {
2070
- if (!block.startsWith('### ')) continue;
2739
+ // decisions: ### 블록 전체 (1.9.14: 코드블록/Template 제외)
2740
+ for (const block of _extractDecisionBlocks(decisions)) {
2071
2741
  const m = block.match(/^### (.+)$/m);
2072
2742
  if (!m) continue;
2073
2743
  lessons.push({ source: 'decisions.md', title: m[1].trim(), block });
@@ -2265,7 +2935,10 @@ function uiConsistency(root) {
2265
2935
  ok(`등록된 디자인 토큰: ${Object.keys(tokens).length}개`);
2266
2936
  const findings = [];
2267
2937
  for (const f of walkCode(root)) {
2268
- if (rel(root, f).startsWith('.harness/')) continue;
2938
+ const r = rel(root, f);
2939
+ if (r.startsWith('.harness/')) continue;
2940
+ // 1.9.12: leerness가 자동 생성하는 roadmap.html은 ui consistency 검사 대상 아님
2941
+ if (r === 'roadmap.html' || /\/roadmap\.html$/.test(r)) continue;
2269
2942
  if (!/\.(css|scss|sass|less|html|jsx|tsx|vue|svelte|js|ts)$/i.test(f)) continue;
2270
2943
  let text; try { text = read(f); } catch { continue; }
2271
2944
  const hexes = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)];
@@ -2523,7 +3196,11 @@ function viewworkInstall(root) {
2523
3196
 
2524
3197
  function help() {
2525
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
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)
2526
3202
  leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
3203
+ leerness roadmap auto on|off|status [--on-every-change] [--out file.html] # 자동 갱신 (1.9.12, install/session-close 기본 ON)
2527
3204
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
2528
3205
  leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
2529
3206
  leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
@@ -2572,6 +3249,10 @@ async function main() {
2572
3249
  if (cmd === 'gate') return gate(args[1] || process.cwd());
2573
3250
  if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
2574
3251
  if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
3252
+ if (cmd === 'retro') return retroCmd(args[1] || process.cwd());
3253
+ if (cmd === 'insights') return insightsCmd(args[1] || process.cwd());
3254
+ if (cmd === 'brainstorm') return brainstormCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')).join(' '));
3255
+ if (cmd === 'roadmap' && args[1] === 'auto') return roadmapAutoCmd(arg('--path', process.cwd()), args[2]);
2575
3256
  if (cmd === 'roadmap') return roadmapCmd(args[1] || process.cwd());
2576
3257
  if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2577
3258
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));