leerness 1.9.10 → 1.9.13
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 +87 -0
- package/bin/harness.js +678 -5
- package/package.json +1 -1
- package/scripts/e2e.js +184 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.13 — 2026-05-13
|
|
4
|
+
|
|
5
|
+
**회고·통찰·브레인스토밍** — 누적된 leerness 데이터에서 자동으로 패턴/추세/주제별 자원을 추출.
|
|
6
|
+
|
|
7
|
+
### Added — 3 신규 명령
|
|
8
|
+
|
|
9
|
+
- **`leerness retro [path] [--days 7]`** — 회고
|
|
10
|
+
- 작업 상태 분포 / 다음 우선 작업 / 스킬 활용 추세 / 최근 결정 / **검증 시간 추세** / 룰 검증률 / fix↔pass 시그널 비율 / 권장 다음 단계
|
|
11
|
+
- **`leerness insights [path]`** — 누적 통계
|
|
12
|
+
- 핵심 지표 / top 스킬 / 검증 시간 통계 / 안정성 (pass÷fix 비율) / 권장
|
|
13
|
+
- **`leerness brainstorm "<주제>"`** — 주제 기반 자원 회수
|
|
14
|
+
- decisions / skills / tasks / rules / evidence에서 매칭 → 관련 과거 실패(lessons) 포함 → 시작 전 권장 액션
|
|
15
|
+
|
|
16
|
+
### Added — 자동 회고
|
|
17
|
+
|
|
18
|
+
- `session close`가 매번 끝에 **한 줄 요약** 자동 출력: `완료 N/M (X%) · 스킬 N종 사용 K회 · 검증 변화 ±X% · 결정 N건 누적`
|
|
19
|
+
- **5세션마다** 자동 깊은 회고 실행 (`.harness/cache/session-counter.json`로 카운팅)
|
|
20
|
+
- 다음 깊은 회고까지 남은 세션 수 안내
|
|
21
|
+
|
|
22
|
+
### Added — 자연어 매핑 (AGENTS.md/CLAUDE.md)
|
|
23
|
+
|
|
24
|
+
| 사용자 발화 | 즉시 실행 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| "회고해줘" / "돌아보자" | `leerness retro` |
|
|
27
|
+
| "통계 / 누적 지표" | `leerness insights` |
|
|
28
|
+
| "X 브레인스토밍 / X 검토" | `leerness brainstorm "X"` |
|
|
29
|
+
|
|
30
|
+
### Migration
|
|
31
|
+
|
|
32
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 마이그레이션. 이후 session close부터 한 줄 요약 자동 출력.
|
|
33
|
+
|
|
34
|
+
## 1.9.12 — 2026-05-13
|
|
35
|
+
|
|
36
|
+
**`leerness roadmap` 자동 생성·갱신** — 3개 트리거.
|
|
37
|
+
|
|
38
|
+
### Added — 자동 roadmap
|
|
39
|
+
|
|
40
|
+
- **`install` 직후 자동 생성**: `npx leerness init .` 끝에 첫 `roadmap.html` 자동 생성. `--no-auto-roadmap`으로 끔.
|
|
41
|
+
- **`session close` 끝 자동 갱신**: `leerness session close .` 마지막에 자동 갱신 출력 라인(`✓ roadmap.html 자동 갱신 (session-close)`).
|
|
42
|
+
- **데이터 변경 즉시 갱신** (옵트인): `--on-every-change`로 켜면 `task add/update/drop`, `plan add`, `rule add/pause/resume` 등이 호출될 때마다 즉시 갱신.
|
|
43
|
+
|
|
44
|
+
### Added — `leerness roadmap auto on|off|status`
|
|
45
|
+
|
|
46
|
+
- `roadmap auto on [--on-every-change] [--out file.html]` — 활성화 + 옵션 조정
|
|
47
|
+
- `roadmap auto off` — 비활성화 (수동 `leerness roadmap`만 작동)
|
|
48
|
+
- `roadmap auto status` — 현재 설정 표시 (enabled / onEveryChange / outFile / 트리거별 활성 여부)
|
|
49
|
+
- 설정 파일: `.harness/cache/auto-roadmap.json`
|
|
50
|
+
- 환경변수 옵트아웃: `LEERNESS_NO_AUTO_ROADMAP=1`
|
|
51
|
+
|
|
52
|
+
### Default
|
|
53
|
+
|
|
54
|
+
신규 init은 **enabled=true / onEveryChange=false**. 가장 자연스러운 워크플로우:
|
|
55
|
+
1. `leerness init . --skills recommended` → 첫 roadmap.html 즉시 생성
|
|
56
|
+
2. 작업 → session close → 자동 갱신
|
|
57
|
+
3. 변경이 많아 즉시 갱신을 원하면 `roadmap auto on --on-every-change`
|
|
58
|
+
|
|
59
|
+
### Migration
|
|
60
|
+
|
|
61
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 마이그레이션. 이후 첫 session close부터 자동 갱신.
|
|
62
|
+
|
|
63
|
+
## 1.9.11 — 2026-05-12
|
|
64
|
+
|
|
65
|
+
**`leerness roadmap` 명령 통합 + `project-roadmap-generator` 스킬 기본 추천 + 화이트보드/토큰/상하 중앙정렬**.
|
|
66
|
+
|
|
67
|
+
### Added — `leerness roadmap [path] [--out file.html]`
|
|
68
|
+
|
|
69
|
+
`project-roadmap-generator` 로직을 leerness 본 패키지에 통합. 외부 의존성 없이 즉시 사용 가능.
|
|
70
|
+
|
|
71
|
+
- 좌→우 수평 트리 (project → milestones → tasks → skills/rules)
|
|
72
|
+
- **상하 중앙정렬**: 각 column의 노드들이 캔버스 세로 중앙 기준으로 균등 분포
|
|
73
|
+
- **디자인 토큰 자동 주입**: `.harness/design-system.md`의 Tokens 표 + 프로젝트 `styles/tokens.css`의 CSS 변수를 HTML `:root`에 `--lr-*`로 주입 (h1·card·border·dot 색상이 사용자 토큰을 따름)
|
|
74
|
+
- **화이트보드**: 드래그 panning, 휠 zoom (마우스 포인터 중심), 더블클릭 reset, +/-/⟳ 컨트롤 버튼
|
|
75
|
+
- 7개 상태 (완료/진행/보류/검토/예정/미완료/오류) + 스킬/룰 색상
|
|
76
|
+
- Milestones, 예정 작업, 보유 스킬, 활성 룰, 최근 결정, 디자인 토큰 6개 섹션 통합
|
|
77
|
+
|
|
78
|
+
### Changed — `recommended` 스킬에 자동 포함
|
|
79
|
+
|
|
80
|
+
`leerness init . --skills recommended` 호출 시 `project-roadmap-generator` 스킬이 기본으로 설치됩니다 (기존 4종 + 1). 별도 설치 불필요.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
recommended = ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator']
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Migration
|
|
87
|
+
|
|
88
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. `leerness roadmap`이 바로 사용 가능합니다.
|
|
89
|
+
|
|
3
90
|
## 1.9.10 — 2026-05-12
|
|
4
91
|
|
|
5
92
|
**leerness-skillpack 분리 + release publish 강화 (git remote 자동 감지 + GitHub Release + gh-pages 배포)**.
|
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.
|
|
9
|
+
const VERSION = '1.9.13';
|
|
10
10
|
const MARK = '<!-- leerness:managed -->';
|
|
11
11
|
const README_START = '<!-- leerness:project-readme:start -->';
|
|
12
12
|
const README_END = '<!-- leerness:project-readme:end -->';
|
|
@@ -71,7 +71,9 @@ const BUILTIN_CATALOG = {
|
|
|
71
71
|
'ads-analytics': { displayNameKo: '광고·GA4 분석 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['GA4 이벤트/전환 점검', '광고 데이터 수집 구조화', '소스/매체 분석', '리포트 자동화'] },
|
|
72
72
|
'appstore-review': { displayNameKo: '앱스토어 심사 대응 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['심사 문구 분석', '개인정보 라벨 점검', '리젝 대응 초안', '웹뷰/앱 데이터 수집 구분'] },
|
|
73
73
|
'ai-verified-skill-publisher': { displayNameKo: 'AI 검증 스킬 업로드·라이브러리화 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['검증된 스킬 정규화', '민감정보 스캔', 'AI 검증 메타데이터 작성', 'npm/git 업로드 dry-run 및 실행 게이트'] },
|
|
74
|
-
'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] }
|
|
74
|
+
'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] },
|
|
75
|
+
// 1.9.11: 기본 내장 — 로드맵 자동 생성 스킬
|
|
76
|
+
'project-roadmap-generator': { displayNameKo: '프로젝트 로드맵 자동 생성 스킬', version: '0.2.0', lastUpdated: '2026-05-12', verification: 'passed', capabilities: ['leerness .harness/* 통합 파싱 (plan/progress/skills/rules/decisions/handoff/current-state)', '좌→우 수평 트리 + 상하 중앙정렬 SVG', '7개 상태 색상 (완료/진행/보류/검토/예정/미완료/오류)', 'design-system + CSS variables 자동 주입', '화이트보드 panning/zoom + 더블클릭 reset', '단일 HTML 출력 (외부 의존성 0)'] }
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
// 1.9.10: skillCatalog는 skillpack 우선, fallback builtin. _loadSkillCatalog 호출은 BUILTIN_CATALOG 정의 후.
|
|
@@ -196,7 +198,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = []) {
|
|
|
196
198
|
const project = detectProjectName(root);
|
|
197
199
|
const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
|
|
198
200
|
return {
|
|
199
|
-
'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`,
|
|
200
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`,
|
|
201
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`,
|
|
202
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`,
|
|
@@ -350,7 +352,8 @@ function syncReadme(root) {
|
|
|
350
352
|
function parseSkillsValue(v) {
|
|
351
353
|
if (!v || v === true) return [];
|
|
352
354
|
if (v === 'all') return Object.keys(skillCatalog);
|
|
353
|
-
|
|
355
|
+
// 1.9.11: recommended에 project-roadmap-generator 자동 포함
|
|
356
|
+
if (v === 'recommended') return ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator'];
|
|
354
357
|
return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => skillCatalog[s]);
|
|
355
358
|
}
|
|
356
359
|
|
|
@@ -455,6 +458,10 @@ async function install(root, opts = {}) {
|
|
|
455
458
|
if (!has('--no-auto-update')) {
|
|
456
459
|
try { autoUpdateInstall(root); } catch (e) { warn('auto-update hook install skipped: ' + (e && e.message)); }
|
|
457
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
|
+
}
|
|
458
465
|
}
|
|
459
466
|
}
|
|
460
467
|
|
|
@@ -686,6 +693,7 @@ function planAdd(root, text) {
|
|
|
686
693
|
const tid = nextId(root, 'T');
|
|
687
694
|
upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction: arg('--next', '다음 액션 작성') });
|
|
688
695
|
ok(`plan added: ${id} → progress: ${tid}`);
|
|
696
|
+
_autoRoadmap(absRoot(root), 'data-change');
|
|
689
697
|
}
|
|
690
698
|
function planDrop(root, text) {
|
|
691
699
|
const id = nextId(root, 'D');
|
|
@@ -722,6 +730,7 @@ function taskAdd(root, text) {
|
|
|
722
730
|
const id = nextId(root, 'T');
|
|
723
731
|
upsertProgress(root, { id, status: arg('--status','requested'), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
|
|
724
732
|
ok(`task added: ${id}`);
|
|
733
|
+
_autoRoadmap(absRoot(root), 'data-change');
|
|
725
734
|
}
|
|
726
735
|
function taskUpdate(root, id) {
|
|
727
736
|
if (!id) return fail('id required (e.g., task update T-0001 --status in-progress)');
|
|
@@ -734,11 +743,13 @@ function taskUpdate(root, id) {
|
|
|
734
743
|
if (arg('--note')) patch.request = arg('--note');
|
|
735
744
|
upsertProgress(root, patch);
|
|
736
745
|
ok(`task updated: ${id}`);
|
|
746
|
+
_autoRoadmap(absRoot(root), 'data-change');
|
|
737
747
|
}
|
|
738
748
|
function taskDrop(root, id) {
|
|
739
749
|
if (!id) return fail('id required');
|
|
740
750
|
upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
|
|
741
751
|
ok(`task dropped: ${id}`);
|
|
752
|
+
_autoRoadmap(absRoot(root), 'data-change');
|
|
742
753
|
}
|
|
743
754
|
|
|
744
755
|
// 1.9.6: 옛 link 손실 row를 plan.md milestone과 자동 매칭 제안/복구.
|
|
@@ -1266,6 +1277,29 @@ function sessionClose(root) {
|
|
|
1266
1277
|
log('\n## Required final response sections');
|
|
1267
1278
|
log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과');
|
|
1268
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
|
+
} catch (e) {
|
|
1301
|
+
warn('retro 요약 실패: ' + (e && e.message ? e.message : e));
|
|
1302
|
+
}
|
|
1269
1303
|
}
|
|
1270
1304
|
|
|
1271
1305
|
function readmeCmd(root) { syncReadme(absRoot(root)); }
|
|
@@ -1314,6 +1348,629 @@ function gate(root) {
|
|
|
1314
1348
|
else ok('all gates passed');
|
|
1315
1349
|
}
|
|
1316
1350
|
|
|
1351
|
+
// ===== 1.9.13: Retrospective / Insights / Brainstorming =====
|
|
1352
|
+
function sessionCounterPath(root) { return path.join(root, '.harness/cache/session-counter.json'); }
|
|
1353
|
+
function readSessionCounter(root) {
|
|
1354
|
+
if (!exists(sessionCounterPath(root))) return { count: 0, lastCloseAt: null, lastDeepRetroAt: null };
|
|
1355
|
+
try { return JSON.parse(read(sessionCounterPath(root))); } catch { return { count: 0, lastCloseAt: null, lastDeepRetroAt: null }; }
|
|
1356
|
+
}
|
|
1357
|
+
function writeSessionCounter(root, c) { writeUtf8(sessionCounterPath(root), JSON.stringify(c, null, 2) + '\n'); }
|
|
1358
|
+
|
|
1359
|
+
function _retroAggregate(root) {
|
|
1360
|
+
root = absRoot(root);
|
|
1361
|
+
const rows = readProgressRows(root);
|
|
1362
|
+
const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
|
|
1363
|
+
const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
|
|
1364
|
+
const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
|
|
1365
|
+
const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
1366
|
+
|
|
1367
|
+
// 1) 작업 상태 분포
|
|
1368
|
+
const statusCounts = {};
|
|
1369
|
+
for (const s of STATUSES) statusCounts[s] = 0;
|
|
1370
|
+
for (const r of rows) if (statusCounts[r.status] != null) statusCounts[r.status]++;
|
|
1371
|
+
|
|
1372
|
+
// 2) 결정 블록 수
|
|
1373
|
+
const decisionBlocks = decisions.split(/\n(?=### )/).filter(b => b.startsWith('### '));
|
|
1374
|
+
// recent decisions (날짜로 정렬 시 가장 최근)
|
|
1375
|
+
const recentDecisions = decisionBlocks.slice(-5).map(b => {
|
|
1376
|
+
const t = (b.match(/^### (.+)$/m) || [, ''])[1];
|
|
1377
|
+
return { title: t.trim(), block: b.slice(0, 200) };
|
|
1378
|
+
}).reverse();
|
|
1379
|
+
|
|
1380
|
+
// 3) 스킬 활용
|
|
1381
|
+
const skillsDir = path.join(root, '.harness/skills');
|
|
1382
|
+
const skillUsage = [];
|
|
1383
|
+
if (exists(skillsDir)) {
|
|
1384
|
+
for (const id of fs.readdirSync(skillsDir)) {
|
|
1385
|
+
const f = path.join(skillsDir, id, 'skill.json');
|
|
1386
|
+
if (!exists(f)) continue;
|
|
1387
|
+
try {
|
|
1388
|
+
const s = JSON.parse(read(f));
|
|
1389
|
+
skillUsage.push({
|
|
1390
|
+
id,
|
|
1391
|
+
displayNameKo: s.displayNameKo || id,
|
|
1392
|
+
count: s.usage?.count || 0,
|
|
1393
|
+
lastUsed: s.usage?.lastUsed || null,
|
|
1394
|
+
optimizations: (s.optimizations || []).length,
|
|
1395
|
+
capabilities: (s.capabilities || []).length
|
|
1396
|
+
});
|
|
1397
|
+
} catch {}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
skillUsage.sort((a, b) => (b.count - a.count) || (b.optimizations - a.optimizations));
|
|
1401
|
+
|
|
1402
|
+
// 4) 검증 시간 추세 — review-evidence.md에서 "exit=0 (Nms)" 또는 "(Nms)" 패턴
|
|
1403
|
+
const durations = [];
|
|
1404
|
+
for (const m of evidence.matchAll(/exit=\d+\s*\((\d+)ms\)/g)) durations.push(parseInt(m[1], 10));
|
|
1405
|
+
|
|
1406
|
+
// 5) 실패→성공 시그널 — task-log/evidence/decisions에서 "롤백" / "fail" / "재발" / "fix" / "수정" 등의 동시 등장 카운트
|
|
1407
|
+
const fixSignals = (tlog + evidence + decisions).match(/\b(fix|fixed|수정|롤백|재발|incomplete|bug)\b/gi) || [];
|
|
1408
|
+
const passSignals = (tlog + evidence + decisions).match(/(?:✓|pass(?:ed)?|통과|completed|done)/gi) || [];
|
|
1409
|
+
|
|
1410
|
+
// 6) 룰 활용
|
|
1411
|
+
const rules = exists(rulesPath(root)) ? readRules(root) : [];
|
|
1412
|
+
const activeRules = rules.filter(r => r.status === 'active');
|
|
1413
|
+
const verifiedRules = rules.filter(r => r.lastVerified && r.lastVerified !== '-');
|
|
1414
|
+
|
|
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)));
|
|
1418
|
+
|
|
1419
|
+
return {
|
|
1420
|
+
statusCounts,
|
|
1421
|
+
rows,
|
|
1422
|
+
totalTasks: rows.length,
|
|
1423
|
+
doneCount: statusCounts.done,
|
|
1424
|
+
decisionBlocks: decisionBlocks.length,
|
|
1425
|
+
recentDecisions,
|
|
1426
|
+
skillUsage,
|
|
1427
|
+
totalSkillUsage: skillUsage.reduce((a, b) => a + b.count, 0),
|
|
1428
|
+
totalOptimizations: skillUsage.reduce((a, b) => a + b.optimizations, 0),
|
|
1429
|
+
durations,
|
|
1430
|
+
fixSignals: fixSignals.length,
|
|
1431
|
+
passSignals: passSignals.length,
|
|
1432
|
+
activeRules: activeRules.length,
|
|
1433
|
+
verifiedRules: verifiedRules.length,
|
|
1434
|
+
focusNext
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function _retroOneLine(agg) {
|
|
1439
|
+
const parts = [];
|
|
1440
|
+
const done = agg.statusCounts.done;
|
|
1441
|
+
const total = agg.totalTasks;
|
|
1442
|
+
if (total) parts.push(`완료 ${done}/${total} (${Math.round(done / total * 100)}%)`);
|
|
1443
|
+
if (agg.totalSkillUsage) parts.push(`스킬 ${agg.skillUsage.length}종 / 사용 ${agg.totalSkillUsage}회 / 최적화 ${agg.totalOptimizations}건`);
|
|
1444
|
+
if (agg.activeRules) parts.push(`룰 ${agg.activeRules}건 활성 (${agg.verifiedRules} 검증됨)`);
|
|
1445
|
+
if (agg.durations.length >= 4) {
|
|
1446
|
+
const mid = Math.floor(agg.durations.length / 2);
|
|
1447
|
+
const a = agg.durations.slice(0, mid).reduce((x, y) => x + y, 0) / mid;
|
|
1448
|
+
const b = agg.durations.slice(mid).reduce((x, y) => x + y, 0) / (agg.durations.length - mid);
|
|
1449
|
+
if (a > 0) {
|
|
1450
|
+
const delta = ((b - a) / a) * 100;
|
|
1451
|
+
const sign = delta > 0 ? '+' : '';
|
|
1452
|
+
parts.push(`검증 ${Math.round(a)}ms→${Math.round(b)}ms (${sign}${delta.toFixed(1)}%)`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
parts.push(`결정 ${agg.decisionBlocks}건 누적`);
|
|
1456
|
+
return parts.join(' · ');
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function retroCmd(root) {
|
|
1460
|
+
root = absRoot(root);
|
|
1461
|
+
const days = parseInt(arg('--days', '7'), 10);
|
|
1462
|
+
const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10);
|
|
1463
|
+
const agg = _retroAggregate(root);
|
|
1464
|
+
log(`# 회고 (retro) — 최근 ${days}일 (since ${cutoff})`);
|
|
1465
|
+
log(`\n📈 한 줄 요약: ${_retroOneLine(agg)}`);
|
|
1466
|
+
|
|
1467
|
+
log(`\n## 작업 상태 분포`);
|
|
1468
|
+
for (const s of STATUSES) if (agg.statusCounts[s]) log(` - ${s}: ${agg.statusCounts[s]}`);
|
|
1469
|
+
|
|
1470
|
+
log(`\n## 🎯 다음 우선 작업 (top 5)`);
|
|
1471
|
+
if (!agg.focusNext.length) log(' (없음 — 새 plan add 권장)');
|
|
1472
|
+
else agg.focusNext.slice(0, 5).forEach(r => log(` - ${r.id} [${r.status}] ${r.request} → ${r.nextAction}`));
|
|
1473
|
+
|
|
1474
|
+
log(`\n## 📚 스킬 활용 추세 (top 5)`);
|
|
1475
|
+
if (!agg.skillUsage.length) log(' (등록된 스킬 없음)');
|
|
1476
|
+
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) : ''}`));
|
|
1477
|
+
|
|
1478
|
+
log(`\n## 🧠 최근 결정 (top 5)`);
|
|
1479
|
+
if (!agg.recentDecisions.length) log(' (없음)');
|
|
1480
|
+
else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
|
|
1481
|
+
|
|
1482
|
+
if (agg.durations.length >= 4) {
|
|
1483
|
+
const mid = Math.floor(agg.durations.length / 2);
|
|
1484
|
+
const a = agg.durations.slice(0, mid).reduce((x, y) => x + y, 0) / mid;
|
|
1485
|
+
const b = agg.durations.slice(mid).reduce((x, y) => x + y, 0) / (agg.durations.length - mid);
|
|
1486
|
+
const delta = ((b - a) / a) * 100;
|
|
1487
|
+
log(`\n## ⏱ 검증 시간 추세 (review-evidence)`);
|
|
1488
|
+
log(` - 전반부 평균: ${Math.round(a)}ms`);
|
|
1489
|
+
log(` - 후반부 평균: ${Math.round(b)}ms`);
|
|
1490
|
+
log(` - 변화: ${delta > 0 ? '+' : ''}${delta.toFixed(1)}% ${delta < 0 ? '🚀 빨라짐' : delta > 10 ? '⚠ 느려짐' : ''}`);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
log(`\n## ⚡ 활성 룰 / 검증 비율`);
|
|
1494
|
+
log(` - 활성 ${agg.activeRules}건 · 검증됨 ${agg.verifiedRules}건 (${agg.activeRules ? Math.round(agg.verifiedRules / agg.activeRules * 100) : 0}%)`);
|
|
1495
|
+
|
|
1496
|
+
log(`\n## 🔁 fix/pass 시그널`);
|
|
1497
|
+
log(` - fix 시그널 (롤백/수정/bug/incomplete): ${agg.fixSignals}회`);
|
|
1498
|
+
log(` - pass 시그널 (통과/✓/completed): ${agg.passSignals}회`);
|
|
1499
|
+
if (agg.passSignals > agg.fixSignals * 2) log(' - 평가: 안정적 (pass >> fix)');
|
|
1500
|
+
else if (agg.fixSignals > agg.passSignals) log(' - 평가: 디버그 비중 높음 — verify-code 자동화 검토');
|
|
1501
|
+
|
|
1502
|
+
log(`\n## 💡 권장 다음 단계`);
|
|
1503
|
+
if (agg.focusNext.length) log(` 1. ${agg.focusNext[0].id} (${agg.focusNext[0].status}): ${agg.focusNext[0].nextAction}`);
|
|
1504
|
+
if (agg.skillUsage.length && agg.skillUsage[0].count > 0) log(` 2. 가장 활발한 스킬 "${agg.skillUsage[0].id}"의 패턴을 다른 작업에 재사용 가능`);
|
|
1505
|
+
if (agg.totalOptimizations > 0) log(` 3. 누적된 최적화 ${agg.totalOptimizations}건을 새 작업의 시작 전 참고 (\`leerness skill info <id>\`)`);
|
|
1506
|
+
log(` 4. \`leerness brainstorm <주제>\`로 누적 데이터 기반 컨텍스트 적재`);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function insightsCmd(root) {
|
|
1510
|
+
root = absRoot(root);
|
|
1511
|
+
const agg = _retroAggregate(root);
|
|
1512
|
+
const sc = readSessionCounter(root);
|
|
1513
|
+
log(`# Insights — 누적 통계`);
|
|
1514
|
+
log(`\n## 📊 핵심 지표`);
|
|
1515
|
+
log(` - 누적 task: ${agg.totalTasks} (done ${agg.doneCount}, in-progress ${agg.statusCounts['in-progress']}, planned ${agg.statusCounts.planned})`);
|
|
1516
|
+
log(` - 누적 결정 (decisions.md): ${agg.decisionBlocks}건`);
|
|
1517
|
+
log(` - 누적 스킬: ${agg.skillUsage.length}종`);
|
|
1518
|
+
log(` - 총 스킬 사용: ${agg.totalSkillUsage}회`);
|
|
1519
|
+
log(` - 총 최적화 누적: ${agg.totalOptimizations}건`);
|
|
1520
|
+
log(` - 활성 룰: ${agg.activeRules}건 (검증 ${agg.verifiedRules}건)`);
|
|
1521
|
+
log(` - session close 횟수: ${sc.count}회${sc.lastCloseAt ? ' (마지막: ' + sc.lastCloseAt.slice(0, 16) + ')' : ''}`);
|
|
1522
|
+
|
|
1523
|
+
if (agg.skillUsage.length) {
|
|
1524
|
+
log(`\n## 🏆 가장 활용도 높은 스킬 (top 5)`);
|
|
1525
|
+
agg.skillUsage.slice(0, 5).forEach((s, i) => log(` ${i + 1}. ${s.id} (${s.displayNameKo}) — 사용 ${s.count}회, 최적화 ${s.optimizations}건`));
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (agg.durations.length) {
|
|
1529
|
+
const total = agg.durations.reduce((a, b) => a + b, 0);
|
|
1530
|
+
log(`\n## ⏱ 검증 시간 (verify-code)`);
|
|
1531
|
+
log(` - 실행: ${agg.durations.length}회 / 총 ${total}ms / 평균 ${Math.round(total / agg.durations.length)}ms`);
|
|
1532
|
+
log(` - 최소 ${Math.min(...agg.durations)}ms / 최대 ${Math.max(...agg.durations)}ms`);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
log(`\n## 🔁 안정성 지표`);
|
|
1536
|
+
log(` - pass 시그널: ${agg.passSignals} · fix 시그널: ${agg.fixSignals}`);
|
|
1537
|
+
const ratio = agg.fixSignals > 0 ? (agg.passSignals / agg.fixSignals).toFixed(2) : '∞';
|
|
1538
|
+
log(` - pass/fix 비율: ${ratio}${ratio === '∞' || parseFloat(ratio) > 3 ? ' (안정)' : parseFloat(ratio) < 1 ? ' (디버그 위주)' : ' (보통)'}`);
|
|
1539
|
+
|
|
1540
|
+
log(`\n## 📈 권장`);
|
|
1541
|
+
if (agg.totalOptimizations === 0) log(` - 스킬에 최적화 누적 없음 — \`leerness skill optimize <id> --before --after\`로 더 나은 방법 기록`);
|
|
1542
|
+
if (sc.count >= 5 && sc.count % 5 === 0) log(` - 5세션마다 자동 깊은 회고가 예정되어 있습니다 — session close가 자동 호출`);
|
|
1543
|
+
if (agg.statusCounts.blocked > 0) log(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function brainstormCmd(root, topic) {
|
|
1547
|
+
root = absRoot(root);
|
|
1548
|
+
if (!topic) return fail('topic required (e.g., brainstorm "API rate limit")');
|
|
1549
|
+
log(`# Brainstorm — "${topic}"`);
|
|
1550
|
+
log(`\n누적된 leerness 데이터에서 주제 관련 자원을 회수합니다.`);
|
|
1551
|
+
|
|
1552
|
+
const re = new RegExp(escapeRegex(topic), 'i');
|
|
1553
|
+
const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
|
|
1554
|
+
|
|
1555
|
+
// decisions
|
|
1556
|
+
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)) {
|
|
1560
|
+
const t = (b.match(/^### (.+)$/m) || [, ''])[1];
|
|
1561
|
+
hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' ') });
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// skills
|
|
1565
|
+
const skillsDir = path.join(root, '.harness/skills');
|
|
1566
|
+
if (exists(skillsDir)) {
|
|
1567
|
+
for (const id of fs.readdirSync(skillsDir)) {
|
|
1568
|
+
const f = path.join(skillsDir, id, 'skill.json');
|
|
1569
|
+
if (!exists(f)) continue;
|
|
1570
|
+
try {
|
|
1571
|
+
const s = JSON.parse(read(f));
|
|
1572
|
+
const text = JSON.stringify(s);
|
|
1573
|
+
if (re.test(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
|
|
1574
|
+
} catch {}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
// tasks
|
|
1578
|
+
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
|
|
1581
|
+
if (exists(rulesPath(root))) {
|
|
1582
|
+
for (const r of readRules(root)) if (re.test(r.rule)) hits.rules.push(r);
|
|
1583
|
+
}
|
|
1584
|
+
// evidence — lessons 키워드 (fail/롤백/incomplete) 동반
|
|
1585
|
+
const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
|
|
1586
|
+
for (const block of ev.split(/\n(?=## )/)) {
|
|
1587
|
+
if (!block.startsWith('## ')) continue;
|
|
1588
|
+
if (re.test(block)) {
|
|
1589
|
+
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() });
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const total = hits.decisions.length + hits.skills.length + hits.tasks.length + hits.rules.length + hits.evidence.length;
|
|
1596
|
+
log(`\n📦 총 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length})`);
|
|
1597
|
+
|
|
1598
|
+
if (hits.decisions.length) {
|
|
1599
|
+
log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
|
|
1600
|
+
hits.decisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
|
|
1601
|
+
}
|
|
1602
|
+
if (hits.skills.length) {
|
|
1603
|
+
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}`));
|
|
1605
|
+
}
|
|
1606
|
+
if (hits.tasks.length) {
|
|
1607
|
+
log(`\n## 📌 관련 과거 task (${hits.tasks.length})`);
|
|
1608
|
+
hits.tasks.slice(0, 5).forEach(t => log(` - ${t.id} [${t.status}] ${t.request}`));
|
|
1609
|
+
}
|
|
1610
|
+
if (hits.rules.length) {
|
|
1611
|
+
log(`\n## ⚡ 관련 룰 (${hits.rules.length})`);
|
|
1612
|
+
hits.rules.forEach(r => log(` - ${r.id} [${r.trigger}] ${r.rule}`));
|
|
1613
|
+
}
|
|
1614
|
+
if (hits.evidence.length) {
|
|
1615
|
+
log(`\n## 🧪 관련 검증 기록 (${hits.evidence.length})`);
|
|
1616
|
+
hits.evidence.slice(0, 5).forEach(e => log(` - ${e.title}`));
|
|
1617
|
+
}
|
|
1618
|
+
if (hits.lessons.length) {
|
|
1619
|
+
log(`\n## ⚠ 같은 주제 과거 실패/롤백 (${hits.lessons.length}) — 같은 실수 방지`);
|
|
1620
|
+
hits.lessons.slice(0, 5).forEach(l => log(` - ${l.title}`));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
log(`\n## 💡 시작 전 권장 액션`);
|
|
1624
|
+
log(` 1. 위 자원을 모두 검토 후 plan add 또는 task add로 새 작업 등록`);
|
|
1625
|
+
log(` 2. 가장 비슷한 과거 스킬을 \`leerness skill use <id>\`로 활성화`);
|
|
1626
|
+
log(` 3. 작업 종료 시 새로 발견한 패턴을 \`skill optimize\`로 누적`);
|
|
1627
|
+
if (!total) log(` ⓘ 관련 자원 없음 — 새로운 영역. 첫 결정/스킬을 기록하면 다음 brainstorm이 더 풍부해짐.`);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// ===== 1.9.11: Roadmap (project-roadmap-generator 통합) =====
|
|
1631
|
+
const ROADMAP_STATUS_LABEL = { done: '완료', 'in-progress': '진행', 'on-hold': '보류', waiting: '검토', incomplete: '미완료', planned: '예정', blocked: '오류', dropped: '취소', skill: '스킬', rule: '룰', meta: '프로젝트' };
|
|
1632
|
+
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' };
|
|
1633
|
+
const ROADMAP_NODE_W = 220, ROADMAP_NODE_H = 72, ROADMAP_COL_GAP = 70, ROADMAP_ROW_GAP = 14;
|
|
1634
|
+
|
|
1635
|
+
function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
|
1636
|
+
function _truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
1637
|
+
|
|
1638
|
+
function _roadmapMapStatus(s) {
|
|
1639
|
+
s = String(s || '').toLowerCase();
|
|
1640
|
+
if (s === 'done' || s === 'in-progress' || s === 'on-hold' || s === 'waiting' || s === 'incomplete' || s === 'blocked' || s === 'dropped') return s;
|
|
1641
|
+
if (s === 'planned' || s === 'requested') return 'planned';
|
|
1642
|
+
return 'planned';
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function _roadmapParseMilestones(text) {
|
|
1646
|
+
const out = [];
|
|
1647
|
+
for (const m of String(text || '').matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)) {
|
|
1648
|
+
const after = text.slice(m.index);
|
|
1649
|
+
const sm = after.match(/^Status:\s*(\S+)/m);
|
|
1650
|
+
const pm = after.match(/^Progress:\s*(\d+)%/m);
|
|
1651
|
+
out.push({ id: m[1], title: m[2].trim(), status: sm ? sm[1] : 'planned', progress: pm ? parseInt(pm[1], 10) : 0 });
|
|
1652
|
+
}
|
|
1653
|
+
return out;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function _roadmapParseTokens(text) {
|
|
1657
|
+
const tokens = {};
|
|
1658
|
+
for (const line of String(text || '').split('\n')) {
|
|
1659
|
+
const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
|
|
1660
|
+
if (!m) continue;
|
|
1661
|
+
const key = m[1].trim(), val = m[2].trim();
|
|
1662
|
+
if (!key || !val || key === 'Token' || /^-+$/.test(key) || val === 'Value' || /\(실제 값으로 업데이트\)/.test(val)) continue;
|
|
1663
|
+
if (val.length > 80) continue;
|
|
1664
|
+
tokens[key] = val;
|
|
1665
|
+
}
|
|
1666
|
+
return tokens;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function _roadmapParseCssVars(root) {
|
|
1670
|
+
const out = {};
|
|
1671
|
+
const cands = ['src/styles/tokens.css', 'styles/tokens.css', 'src/styles.css', 'styles.css', 'src/styles/main.css', 'public/styles.css'];
|
|
1672
|
+
for (const c of cands) {
|
|
1673
|
+
const f = path.join(root, c);
|
|
1674
|
+
if (!exists(f)) continue;
|
|
1675
|
+
const text = read(f);
|
|
1676
|
+
const m = text.match(/:root\s*\{([\s\S]*?)\}/);
|
|
1677
|
+
if (!m) continue;
|
|
1678
|
+
for (const line of m[1].split('\n')) {
|
|
1679
|
+
const v = line.match(/--([\w-]+)\s*:\s*([^;]+);/);
|
|
1680
|
+
if (v) out[v[1].trim()] = v[2].trim();
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return out;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function _roadmapData(root) {
|
|
1687
|
+
root = absRoot(root);
|
|
1688
|
+
const milestones = _roadmapParseMilestones(exists(planPath(root)) ? read(planPath(root)) : '');
|
|
1689
|
+
const tasks = readProgressRows(root).map(t => ({
|
|
1690
|
+
...t,
|
|
1691
|
+
milestones: Array.from(String(t.evidence || '').matchAll(/M-\d{4}/g)).map(m => m[0])
|
|
1692
|
+
}));
|
|
1693
|
+
// skills
|
|
1694
|
+
const skills = [];
|
|
1695
|
+
const skillsDir = path.join(root, '.harness/skills');
|
|
1696
|
+
if (exists(skillsDir)) {
|
|
1697
|
+
for (const id of fs.readdirSync(skillsDir)) {
|
|
1698
|
+
const f = path.join(skillsDir, id, 'skill.json');
|
|
1699
|
+
if (!exists(f)) continue;
|
|
1700
|
+
try { skills.push(JSON.parse(read(f))); } catch {}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
// rules
|
|
1704
|
+
const rulesT = exists(rulesPath(root)) ? read(rulesPath(root)) : '';
|
|
1705
|
+
const rules = [];
|
|
1706
|
+
for (const line of rulesT.split('\n')) {
|
|
1707
|
+
if (!/^\| R-\d{4} \|/.test(line)) continue;
|
|
1708
|
+
const cells = line.split('|').slice(1, -1).map(s => s.trim());
|
|
1709
|
+
if (cells.length < 6) continue;
|
|
1710
|
+
rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], status: cells[4], lastVerified: cells[5] });
|
|
1711
|
+
}
|
|
1712
|
+
// currentState
|
|
1713
|
+
const csT = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
|
|
1714
|
+
const now = (csT.match(/## Now\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
|
|
1715
|
+
const next = (csT.match(/## Next\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
|
|
1716
|
+
const blockers = (csT.match(/## Blockers\n([\s\S]*?)$/) || [, ''])[1].trim();
|
|
1717
|
+
// decisions (top 6)
|
|
1718
|
+
const decT = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
|
|
1719
|
+
const decisions = [];
|
|
1720
|
+
for (const block of decT.split(/\n(?=### )/)) {
|
|
1721
|
+
if (!block.startsWith('### ')) continue;
|
|
1722
|
+
const tm = block.match(/^### (.+)$/m);
|
|
1723
|
+
if (tm) decisions.push({ title: tm[1].trim() });
|
|
1724
|
+
}
|
|
1725
|
+
return {
|
|
1726
|
+
project: path.basename(root),
|
|
1727
|
+
version: exists(path.join(root, '.harness/HARNESS_VERSION')) ? read(path.join(root, '.harness/HARNESS_VERSION')).trim() : 'unknown',
|
|
1728
|
+
milestones, tasks, skills, rules,
|
|
1729
|
+
currentState: { now, next, blockers },
|
|
1730
|
+
decisions,
|
|
1731
|
+
designTokens: _roadmapParseTokens(exists(path.join(root, '.harness/design-system.md')) ? read(path.join(root, '.harness/design-system.md')) : ''),
|
|
1732
|
+
cssVariables: _roadmapParseCssVars(root)
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function _roadmapLayout(data) {
|
|
1737
|
+
const nodes = []; const edges = [];
|
|
1738
|
+
nodes.push({ id: 'project', kind: 'project', title: data.project, subtitle: `leerness ${data.version}`, meta: `M ${data.milestones.length} · T ${data.tasks.length} · S ${data.skills.length}`, status: 'meta', col: 0 });
|
|
1739
|
+
for (const m of data.milestones) {
|
|
1740
|
+
nodes.push({ id: m.id, kind: 'milestone', title: m.id, subtitle: m.title, meta: `${m.progress}% · ${m.status}`, status: _roadmapMapStatus(m.status), col: 1 });
|
|
1741
|
+
edges.push({ from: 'project', to: m.id });
|
|
1742
|
+
}
|
|
1743
|
+
for (const t of data.tasks) {
|
|
1744
|
+
nodes.push({ id: t.id, kind: 'task', title: t.id, subtitle: t.request, meta: t.evidence ? `evidence: ${t.evidence.slice(0, 40)}` : '', status: _roadmapMapStatus(t.status), col: 2 });
|
|
1745
|
+
if (t.milestones.length) for (const mid of t.milestones) edges.push({ from: mid, to: t.id });
|
|
1746
|
+
else edges.push({ from: 'project', to: t.id });
|
|
1747
|
+
}
|
|
1748
|
+
for (const s of data.skills) {
|
|
1749
|
+
nodes.push({ id: 'skill:' + s.name, kind: 'skill', title: s.name, subtitle: s.displayNameKo || s.name, meta: `사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`, status: 'skill', col: 3 });
|
|
1750
|
+
edges.push({ from: 'project', to: 'skill:' + s.name });
|
|
1751
|
+
}
|
|
1752
|
+
for (const r of data.rules.filter(r => r.status === 'active')) {
|
|
1753
|
+
nodes.push({ id: 'rule:' + r.id, kind: 'rule', title: r.id, subtitle: r.rule, meta: r.trigger, status: 'rule', col: 3 });
|
|
1754
|
+
edges.push({ from: 'project', to: 'rule:' + r.id });
|
|
1755
|
+
}
|
|
1756
|
+
// 상하 중앙정렬 (1.9.11 v0.2)
|
|
1757
|
+
const byCol = {};
|
|
1758
|
+
for (const n of nodes) (byCol[n.col] = byCol[n.col] || []).push(n);
|
|
1759
|
+
const colH = {}; let maxColH = 0; let maxCol = 0;
|
|
1760
|
+
for (const c of Object.keys(byCol)) {
|
|
1761
|
+
const r = byCol[c]; const h = r.length * ROADMAP_NODE_H + Math.max(0, r.length - 1) * ROADMAP_ROW_GAP;
|
|
1762
|
+
colH[c] = h; maxColH = Math.max(maxColH, h); maxCol = Math.max(maxCol, parseInt(c, 10));
|
|
1763
|
+
}
|
|
1764
|
+
const padding = 40; const minHeight = 360;
|
|
1765
|
+
const canvasHeight = Math.max(maxColH, minHeight) + padding * 2;
|
|
1766
|
+
for (const c of Object.keys(byCol)) {
|
|
1767
|
+
const r = byCol[c]; const h = colH[c]; const startY = (canvasHeight - h) / 2;
|
|
1768
|
+
r.forEach((n, i) => {
|
|
1769
|
+
n.x = parseInt(c, 10) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding;
|
|
1770
|
+
n.y = startY + i * (ROADMAP_NODE_H + ROADMAP_ROW_GAP);
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
return { nodes, edges, width: (maxCol + 1) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding * 2, height: canvasHeight };
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function _roadmapTokenStyles(designTokens, cssVariables) {
|
|
1777
|
+
const vars = {};
|
|
1778
|
+
const map = [
|
|
1779
|
+
['color.primary', 'color-primary', 'lr-primary'], ['color.surface', 'color-surface', 'lr-surface'],
|
|
1780
|
+
['color.text', 'color-text', 'lr-text'], ['color.muted', 'color-muted', 'lr-muted'],
|
|
1781
|
+
['space.1', 'space-1', 'lr-space-1'], ['space.2', 'space-2', 'lr-space-2'],
|
|
1782
|
+
['space.3', 'space-3', 'lr-space-3'], ['space.4', 'space-4', 'lr-space-4'],
|
|
1783
|
+
['radius', 'radius', 'lr-radius']
|
|
1784
|
+
];
|
|
1785
|
+
for (const [ds, css, vn] of map) { const v = cssVariables[css] || designTokens[ds]; if (v) vars[vn] = v; }
|
|
1786
|
+
for (const [k, v] of Object.entries(cssVariables)) if (!vars[`lr-${k}`]) vars[`lr-${k}`] = v;
|
|
1787
|
+
if (!vars['lr-card-bg']) vars['lr-card-bg'] = vars['lr-surface'] || '#ffffff';
|
|
1788
|
+
if (!vars['lr-edge']) vars['lr-edge'] = vars['lr-muted'] || '#cbd5e1';
|
|
1789
|
+
if (!vars['lr-page-bg']) vars['lr-page-bg'] = '#f8fafc';
|
|
1790
|
+
return ':root {\n' + Object.entries(vars).map(([k, v]) => ` --${k}: ${v};`).join('\n') + '\n }';
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function _roadmapHTML(data) {
|
|
1794
|
+
const g = _roadmapLayout(data);
|
|
1795
|
+
const edges = g.edges.map(e => {
|
|
1796
|
+
const f = g.nodes.find(n => n.id === e.from), t = g.nodes.find(n => n.id === e.to);
|
|
1797
|
+
if (!f || !t) return '';
|
|
1798
|
+
const x1 = f.x + ROADMAP_NODE_W, y1 = f.y + ROADMAP_NODE_H / 2, x2 = t.x, y2 = t.y + ROADMAP_NODE_H / 2, mid = (x1 + x2) / 2;
|
|
1799
|
+
return `<path d="M ${x1},${y1} C ${mid},${y1} ${mid},${y2} ${x2},${y2}" stroke="var(--lr-edge, #cbd5e1)" stroke-width="1.5" fill="none"/>`;
|
|
1800
|
+
}).join('\n');
|
|
1801
|
+
const nodes = g.nodes.map(n => {
|
|
1802
|
+
const c = ROADMAP_STATUS_COLOR[n.status] || 'var(--lr-text, #0f172a)';
|
|
1803
|
+
const lbl = ROADMAP_STATUS_LABEL[n.status] || n.status;
|
|
1804
|
+
return `<g class="node node-${n.kind} status-${n.status}" data-id="${_esc(n.id)}" transform="translate(${n.x},${n.y})">
|
|
1805
|
+
<rect width="${ROADMAP_NODE_W}" height="${ROADMAP_NODE_H}" rx="8" ry="8" fill="var(--lr-card-bg, #ffffff)" stroke="${c}" stroke-width="2"/>
|
|
1806
|
+
<rect width="5" height="${ROADMAP_NODE_H}" fill="${c}"/>
|
|
1807
|
+
<text x="14" y="22" font-size="12" fill="${c}" font-weight="600">${_esc(n.title)} · ${_esc(lbl)}</text>
|
|
1808
|
+
<text x="14" y="42" font-size="11" fill="var(--lr-text, #1f2937)" font-weight="500">${_esc(_truncate(n.subtitle, 30))}</text>
|
|
1809
|
+
<text x="14" y="60" font-size="10" fill="var(--lr-muted, #64748b)">${_esc(_truncate(n.meta, 36))}</text>
|
|
1810
|
+
<title>${_esc(n.id)} — ${_esc(n.subtitle)}${n.meta ? '\n' + _esc(n.meta) : ''}</title>
|
|
1811
|
+
</g>`;
|
|
1812
|
+
}).join('\n');
|
|
1813
|
+
const counts = {};
|
|
1814
|
+
for (const t of data.tasks) counts[t.status] = (counts[t.status] || 0) + 1;
|
|
1815
|
+
const legend = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked', 'skill', 'rule']
|
|
1816
|
+
.map(s => `<span class="badge" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]}</span>`).join(' ');
|
|
1817
|
+
const chips = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked']
|
|
1818
|
+
.map(s => `<span class="chip" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]} ${counts[s] || 0}</span>`).join(' ');
|
|
1819
|
+
const upcoming = data.tasks.filter(t => ['planned', 'requested', 'in-progress'].includes(t.status)).slice(0, 10);
|
|
1820
|
+
const upcomingBlock = upcoming.length ? upcoming.map(t => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR[t.status] || '#000'}"></span><strong>${_esc(t.id)}</strong> <span class="meta">[${_esc(ROADMAP_STATUS_LABEL[t.status] || t.status)}]</span> ${_esc(t.request)} <span class="meta">→ ${_esc(t.nextAction)}</span></div>`).join('') : '<div class="empty">예정 작업 없음</div>';
|
|
1821
|
+
const milestoneBlock = data.milestones.length ? data.milestones.map(m => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR[_roadmapMapStatus(m.status)] || ROADMAP_STATUS_COLOR.planned}"></span><strong>${_esc(m.id)}</strong> <span class="meta">[${_esc(m.status)} · ${m.progress}%]</span> ${_esc(m.title)}</div>`).join('') : '<div class="empty">마일스톤 없음</div>';
|
|
1822
|
+
const skillsBlock = data.skills.length ? data.skills.map(s => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR.skill}"></span><strong>${_esc(s.name)}</strong> · ${_esc(s.displayNameKo || s.name)} <span class="meta">사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}</span></div>`).join('') : '<div class="empty">스킬 없음</div>';
|
|
1823
|
+
const activeRules = data.rules.filter(r => r.status === 'active');
|
|
1824
|
+
const rulesBlock = activeRules.length ? activeRules.map(r => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR.rule}"></span><strong>${_esc(r.id)}</strong> <span class="meta">[${_esc(r.trigger)}]</span> ${_esc(r.rule)}</div>`).join('') : '<div class="empty">활성 룰 없음</div>';
|
|
1825
|
+
const decisionsBlock = data.decisions.length ? data.decisions.slice(0, 6).map(d => `<div class="row"><span class="dot" style="background:var(--lr-text, #0f172a)"></span>${_esc(d.title)}</div>`).join('') : '<div class="empty">결정 없음</div>';
|
|
1826
|
+
const tokensSection = (Object.keys(data.designTokens).length || Object.keys(data.cssVariables).length)
|
|
1827
|
+
? [...Object.entries(data.designTokens).slice(0, 8), ...Object.entries(data.cssVariables).slice(0, 8)]
|
|
1828
|
+
.map(([k, v]) => `<div class="row"><span class="dot" style="background:${/#[0-9a-f]{3,8}/i.test(v) ? v : 'var(--lr-muted, #94a3b8)'}"></span><strong>${_esc(k)}</strong> <span class="meta">${_esc(v)}</span></div>`).join('')
|
|
1829
|
+
: '<div class="empty">디자인 토큰 없음</div>';
|
|
1830
|
+
|
|
1831
|
+
return `<!doctype html>
|
|
1832
|
+
<html lang="ko"><head><meta charset="utf-8"><title>${_esc(data.project)} — leerness 로드맵</title>
|
|
1833
|
+
<style>
|
|
1834
|
+
${_roadmapTokenStyles(data.designTokens, data.cssVariables)}
|
|
1835
|
+
body { font-family: var(--lr-font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', sans-serif); margin: 0; padding: 20px; background: var(--lr-page-bg); color: var(--lr-text); }
|
|
1836
|
+
h1 { margin: 0 0 4px; font-size: 22px; color: var(--lr-primary, var(--lr-text, #0f172a)); }
|
|
1837
|
+
h2 { margin: 24px 0 8px; font-size: 16px; color: var(--lr-muted, #334155); }
|
|
1838
|
+
.meta { font-size: 11px; color: var(--lr-muted, #64748b); margin-left: 4px; }
|
|
1839
|
+
.summary { display: flex; gap: 16px; flex-wrap: wrap; background: var(--lr-card-bg); padding: 12px 16px; border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); font-size: 13px; }
|
|
1840
|
+
.legend { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
|
|
1841
|
+
.badge, .chip { display: inline-block; padding: 2px 10px; border: 1.5px solid var(--lr-muted, #94a3b8); border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--lr-card-bg); }
|
|
1842
|
+
.chip { padding: 3px 10px; }
|
|
1843
|
+
.block { background: var(--lr-card-bg); padding: 12px 16px; border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); margin: 8px 0; }
|
|
1844
|
+
.row { font-size: 13px; padding: 4px 0; border-bottom: 1px dashed var(--lr-muted, #f1f5f9); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
1845
|
+
.row:last-child { border-bottom: none; }
|
|
1846
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
1847
|
+
.empty { font-size: 12px; color: var(--lr-muted, #94a3b8); font-style: italic; padding: 4px 0; }
|
|
1848
|
+
.roadmap-wrap { position: relative; background: var(--lr-card-bg); border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); height: 640px; overflow: hidden; cursor: grab; }
|
|
1849
|
+
.roadmap-wrap.grabbing { cursor: grabbing; }
|
|
1850
|
+
.roadmap-wrap svg { display: block; width: 100%; height: 100%; }
|
|
1851
|
+
.node:hover rect:first-of-type { fill: var(--lr-page-bg, #f1f5f9); cursor: pointer; }
|
|
1852
|
+
.node text { user-select: none; pointer-events: none; }
|
|
1853
|
+
.controls { position: absolute; top: 12px; right: 12px; display: flex; gap: 6px; background: var(--lr-card-bg); padding: 6px; border-radius: 8px; border: 1px solid var(--lr-muted, #e2e8f0); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
|
1854
|
+
.controls button { width: 32px; height: 32px; border: 1px solid var(--lr-muted, #cbd5e1); background: var(--lr-card-bg); color: var(--lr-text); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; }
|
|
1855
|
+
.controls button:hover { background: var(--lr-page-bg); }
|
|
1856
|
+
.footer { color: var(--lr-muted, #94a3b8); font-size: 11px; text-align: right; margin-top: 16px; }
|
|
1857
|
+
.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
1858
|
+
@media (max-width: 900px) { .columns { grid-template-columns: 1fr; } }
|
|
1859
|
+
</style></head>
|
|
1860
|
+
<body>
|
|
1861
|
+
<h1>${_esc(data.project)} — leerness 로드맵</h1>
|
|
1862
|
+
<div class="meta">자동 생성 · ${new Date().toISOString().slice(0, 16).replace('T', ' ')} · leerness v${_esc(data.version)}</div>
|
|
1863
|
+
<div class="summary">
|
|
1864
|
+
<div><strong>milestones:</strong> ${data.milestones.length}</div>
|
|
1865
|
+
<div><strong>tasks:</strong> ${data.tasks.length}</div>
|
|
1866
|
+
<div><strong>skills:</strong> ${data.skills.length}</div>
|
|
1867
|
+
<div><strong>active rules:</strong> ${activeRules.length}</div>
|
|
1868
|
+
<div><strong>decisions:</strong> ${data.decisions.length}</div>
|
|
1869
|
+
<div><strong>design tokens:</strong> ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}</div>
|
|
1870
|
+
</div>
|
|
1871
|
+
<div class="legend">${legend}</div>
|
|
1872
|
+
<div class="legend">${chips}</div>
|
|
1873
|
+
<h2>📍 Current State</h2>
|
|
1874
|
+
<div class="block">
|
|
1875
|
+
<div class="row"><strong>Now:</strong> ${_esc(data.currentState.now || '-')}</div>
|
|
1876
|
+
<div class="row"><strong>Next:</strong> ${_esc(data.currentState.next || '-')}</div>
|
|
1877
|
+
<div class="row"><strong>Blockers:</strong> ${_esc(data.currentState.blockers || '-')}</div>
|
|
1878
|
+
</div>
|
|
1879
|
+
<h2>🗺️ Roadmap — 화이트보드 (드래그 panning · 휠 zoom · 더블클릭 reset)</h2>
|
|
1880
|
+
<div class="roadmap-wrap" id="roadmap-board">
|
|
1881
|
+
<svg id="roadmap-svg" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
|
|
1882
|
+
<g class="viewport">
|
|
1883
|
+
<g class="edges">${edges}</g>
|
|
1884
|
+
<g class="nodes">${nodes}</g>
|
|
1885
|
+
</g>
|
|
1886
|
+
</svg>
|
|
1887
|
+
<div class="controls"><button onclick="lrZoom(0.9)">−</button><button onclick="lrZoom(1.1)">+</button><button onclick="lrReset()">⟳</button></div>
|
|
1888
|
+
</div>
|
|
1889
|
+
<div class="columns">
|
|
1890
|
+
<div>
|
|
1891
|
+
<h2>🎯 Milestones (${data.milestones.length})</h2><div class="block">${milestoneBlock}</div>
|
|
1892
|
+
<h2>📌 다음 예정 작업</h2><div class="block">${upcomingBlock}</div>
|
|
1893
|
+
<h2>📚 보유 스킬 (${data.skills.length})</h2><div class="block">${skillsBlock}</div>
|
|
1894
|
+
</div>
|
|
1895
|
+
<div>
|
|
1896
|
+
<h2>⚡ Active Rules (${activeRules.length})</h2><div class="block">${rulesBlock}</div>
|
|
1897
|
+
<h2>🧠 최근 결정</h2><div class="block">${decisionsBlock}</div>
|
|
1898
|
+
<h2>🎨 디자인 토큰</h2><div class="block">${tokensSection}</div>
|
|
1899
|
+
</div>
|
|
1900
|
+
</div>
|
|
1901
|
+
<div class="footer">leerness roadmap · v${_esc(data.version)} · 화이트보드 + 토큰 주입 + 상하 중앙정렬</div>
|
|
1902
|
+
<script>
|
|
1903
|
+
(function(){var svg=document.getElementById('roadmap-svg');var board=document.getElementById('roadmap-board');var vp=svg.querySelector('.viewport');var tx=0,ty=0,scale=1;var dragging=false,sx=0,sy=0;function apply(){vp.setAttribute('transform','translate('+tx+','+ty+') scale('+scale+')');}board.addEventListener('mousedown',function(e){if(e.target.closest&&(e.target.closest('.node')||e.target.closest('.controls')))return;dragging=true;sx=e.clientX-tx;sy=e.clientY-ty;board.classList.add('grabbing');e.preventDefault();});window.addEventListener('mousemove',function(e){if(!dragging)return;tx=e.clientX-sx;ty=e.clientY-sy;apply();});window.addEventListener('mouseup',function(){dragging=false;board.classList.remove('grabbing');});board.addEventListener('wheel',function(e){e.preventDefault();var d=e.deltaY>0?0.9:1.1;var rect=board.getBoundingClientRect();var cx=e.clientX-rect.left;var cy=e.clientY-rect.top;var ns=Math.max(0.3,Math.min(3.0,scale*d));var r=ns/scale;tx=cx-(cx-tx)*r;ty=cy-(cy-ty)*r;scale=ns;apply();},{passive:false});board.addEventListener('dblclick',function(){tx=0;ty=0;scale=1;apply();});window.lrZoom=function(d){scale=Math.max(0.3,Math.min(3.0,scale*d));apply();};window.lrReset=function(){tx=0;ty=0;scale=1;apply();};})();
|
|
1904
|
+
</script>
|
|
1905
|
+
</body></html>`;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function roadmapCmd(root) {
|
|
1909
|
+
root = absRoot(root);
|
|
1910
|
+
if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음 — 먼저 \`leerness init .\``);
|
|
1911
|
+
const outFile = path.resolve(arg('--out', null) || path.join(root, 'roadmap.html'));
|
|
1912
|
+
const data = _roadmapData(root);
|
|
1913
|
+
writeUtf8(outFile, _roadmapHTML(data));
|
|
1914
|
+
ok(`로드맵 생성: ${rel(root, outFile)}`);
|
|
1915
|
+
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}`);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// 1.9.12: auto roadmap (install / session-close / 옵트인 data-change 트리거)
|
|
1919
|
+
function _autoRoadmapConfigPath(root) { return path.join(root, '.harness/cache/auto-roadmap.json'); }
|
|
1920
|
+
function _autoRoadmapConfig(root) {
|
|
1921
|
+
const f = _autoRoadmapConfigPath(root);
|
|
1922
|
+
const def = { enabled: true, onEveryChange: false, outFile: null };
|
|
1923
|
+
if (!exists(f)) return def;
|
|
1924
|
+
try { return Object.assign(def, JSON.parse(read(f))); } catch { return def; }
|
|
1925
|
+
}
|
|
1926
|
+
function _saveAutoRoadmapConfig(root, cfg) {
|
|
1927
|
+
writeUtf8(_autoRoadmapConfigPath(root), JSON.stringify(cfg, null, 2) + '\n');
|
|
1928
|
+
}
|
|
1929
|
+
function _autoRoadmap(root, trigger) {
|
|
1930
|
+
try {
|
|
1931
|
+
if (process.env.LEERNESS_NO_AUTO_ROADMAP === '1') return false;
|
|
1932
|
+
if (!exists(path.join(root, '.harness'))) return false;
|
|
1933
|
+
const cfg = _autoRoadmapConfig(root);
|
|
1934
|
+
if (!cfg.enabled) return false;
|
|
1935
|
+
if (trigger === 'data-change' && !cfg.onEveryChange) return false;
|
|
1936
|
+
const outFile = path.resolve(cfg.outFile || path.join(root, 'roadmap.html'));
|
|
1937
|
+
const data = _roadmapData(root);
|
|
1938
|
+
writeUtf8(outFile, _roadmapHTML(data));
|
|
1939
|
+
log(`✓ roadmap.html 자동 갱신 (${trigger}) — ${rel(root, outFile)}`);
|
|
1940
|
+
return true;
|
|
1941
|
+
} catch (e) {
|
|
1942
|
+
warn('roadmap 자동 갱신 실패: ' + (e && e.message ? e.message : e));
|
|
1943
|
+
return false;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function roadmapAutoCmd(root, sub) {
|
|
1948
|
+
root = absRoot(root);
|
|
1949
|
+
if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음`);
|
|
1950
|
+
const cfg = _autoRoadmapConfig(root);
|
|
1951
|
+
if (sub === 'on') {
|
|
1952
|
+
cfg.enabled = true;
|
|
1953
|
+
if (has('--on-every-change')) cfg.onEveryChange = true;
|
|
1954
|
+
if (has('--no-on-every-change')) cfg.onEveryChange = false;
|
|
1955
|
+
if (arg('--out', null)) cfg.outFile = arg('--out', null);
|
|
1956
|
+
_saveAutoRoadmapConfig(root, cfg);
|
|
1957
|
+
ok(`auto-roadmap 활성화 (onEveryChange: ${cfg.onEveryChange}, outFile: ${cfg.outFile || './roadmap.html'})`);
|
|
1958
|
+
} else if (sub === 'off') {
|
|
1959
|
+
cfg.enabled = false;
|
|
1960
|
+
_saveAutoRoadmapConfig(root, cfg);
|
|
1961
|
+
ok('auto-roadmap 비활성화 — session close 시 갱신 안 됨');
|
|
1962
|
+
} else {
|
|
1963
|
+
log(`# auto-roadmap status`);
|
|
1964
|
+
log(`enabled: ${cfg.enabled}`);
|
|
1965
|
+
log(`onEveryChange: ${cfg.onEveryChange}`);
|
|
1966
|
+
log(`outFile: ${cfg.outFile || './roadmap.html'}`);
|
|
1967
|
+
log(`\n트리거:`);
|
|
1968
|
+
log(` install : ${cfg.enabled ? '✓ 자동 생성' : '✗ 비활성'}`);
|
|
1969
|
+
log(` session-close: ${cfg.enabled ? '✓ 자동 갱신' : '✗ 비활성'}`);
|
|
1970
|
+
log(` data-change : ${cfg.enabled && cfg.onEveryChange ? '✓ 즉시 갱신 (모든 task/plan/rule/skill 변경)' : '✗ 옵트인 필요 (--on-every-change)'}`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1317
1974
|
// ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
|
|
1318
1975
|
function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
|
|
1319
1976
|
function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
|
|
@@ -1392,6 +2049,7 @@ function ruleAdd(root, description) {
|
|
|
1392
2049
|
rules.push({ id, trigger, rule: description, added: today(), status: 'active', lastVerified: '-' });
|
|
1393
2050
|
writeRules(root, rules);
|
|
1394
2051
|
ok(`rule added: ${id} [${trigger}] ${description}`);
|
|
2052
|
+
_autoRoadmap(root, 'data-change');
|
|
1395
2053
|
}
|
|
1396
2054
|
|
|
1397
2055
|
function ruleList(root) {
|
|
@@ -1425,6 +2083,7 @@ function rulePause(root, id) {
|
|
|
1425
2083
|
r.status = 'paused';
|
|
1426
2084
|
writeRules(root, rules);
|
|
1427
2085
|
ok(`rule paused: ${id}`);
|
|
2086
|
+
_autoRoadmap(root, 'data-change');
|
|
1428
2087
|
}
|
|
1429
2088
|
|
|
1430
2089
|
function ruleResume(root, id) {
|
|
@@ -1436,6 +2095,7 @@ function ruleResume(root, id) {
|
|
|
1436
2095
|
r.status = 'active';
|
|
1437
2096
|
writeRules(root, rules);
|
|
1438
2097
|
ok(`rule resumed: ${id}`);
|
|
2098
|
+
_autoRoadmap(root, 'data-change');
|
|
1439
2099
|
}
|
|
1440
2100
|
|
|
1441
2101
|
function ruleStop(root) {
|
|
@@ -1974,7 +2634,10 @@ function uiConsistency(root) {
|
|
|
1974
2634
|
ok(`등록된 디자인 토큰: ${Object.keys(tokens).length}개`);
|
|
1975
2635
|
const findings = [];
|
|
1976
2636
|
for (const f of walkCode(root)) {
|
|
1977
|
-
|
|
2637
|
+
const r = rel(root, f);
|
|
2638
|
+
if (r.startsWith('.harness/')) continue;
|
|
2639
|
+
// 1.9.12: leerness가 자동 생성하는 roadmap.html은 ui consistency 검사 대상 아님
|
|
2640
|
+
if (r === 'roadmap.html' || /\/roadmap\.html$/.test(r)) continue;
|
|
1978
2641
|
if (!/\.(css|scss|sass|less|html|jsx|tsx|vue|svelte|js|ts)$/i.test(f)) continue;
|
|
1979
2642
|
let text; try { text = read(f); } catch { continue; }
|
|
1980
2643
|
const hexes = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)];
|
|
@@ -2232,6 +2895,11 @@ function viewworkInstall(root) {
|
|
|
2232
2895
|
|
|
2233
2896
|
function help() {
|
|
2234
2897
|
log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
|
|
2898
|
+
leerness retro [path] [--days 7] # 회고 (1.9.13) — 작업/스킬/결정/검증 시간 추세 + 권장
|
|
2899
|
+
leerness insights [path] # 누적 통계 (1.9.13) — 핵심 지표 + 안정성
|
|
2900
|
+
leerness brainstorm "<주제>" # 브레인스토밍 (1.9.13) — 누적 자원 회수 + 시작 컨텍스트
|
|
2901
|
+
leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
|
|
2902
|
+
leerness roadmap auto on|off|status [--on-every-change] [--out file.html] # 자동 갱신 (1.9.12, install/session-close 기본 ON)
|
|
2235
2903
|
leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
|
|
2236
2904
|
leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
|
|
2237
2905
|
leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
|
|
@@ -2280,6 +2948,11 @@ async function main() {
|
|
|
2280
2948
|
if (cmd === 'gate') return gate(args[1] || process.cwd());
|
|
2281
2949
|
if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
|
|
2282
2950
|
if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
|
|
2951
|
+
if (cmd === 'retro') return retroCmd(args[1] || process.cwd());
|
|
2952
|
+
if (cmd === 'insights') return insightsCmd(args[1] || process.cwd());
|
|
2953
|
+
if (cmd === 'brainstorm') return brainstormCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')).join(' '));
|
|
2954
|
+
if (cmd === 'roadmap' && args[1] === 'auto') return roadmapAutoCmd(arg('--path', process.cwd()), args[2]);
|
|
2955
|
+
if (cmd === 'roadmap') return roadmapCmd(args[1] || process.cwd());
|
|
2283
2956
|
if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
|
|
2284
2957
|
if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
|
|
2285
2958
|
if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
|
package/package.json
CHANGED
package/scripts/e2e.js
CHANGED
|
@@ -6,13 +6,15 @@ const os = require('os');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const cp = require('child_process');
|
|
8
8
|
|
|
9
|
+
// 1.9.12: e2e 안정성을 위해 자식 프로세스의 npm 호출 차단 (hang 방지)
|
|
10
|
+
process.env.LEERNESS_OFFLINE = process.env.LEERNESS_OFFLINE || '1';
|
|
9
11
|
const CLI = path.resolve(__dirname, '..', 'bin', 'harness.js');
|
|
10
12
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-e2e-'));
|
|
11
13
|
let failed = 0; let total = 0;
|
|
12
14
|
|
|
13
15
|
function run(label, args, opts = {}) {
|
|
14
16
|
total++;
|
|
15
|
-
const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: opts.cwd || tmp, encoding: 'utf8' });
|
|
17
|
+
const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: opts.cwd || tmp, encoding: 'utf8', timeout: 30000 });
|
|
16
18
|
const ok = (r.status === 0) === !opts.expectFail;
|
|
17
19
|
process.stdout.write(`${ok ? '✓' : '✗'} ${label} (exit=${r.status})\n`);
|
|
18
20
|
if (!ok) { failed++; process.stdout.write(r.stdout || ''); process.stderr.write(r.stderr || ''); }
|
|
@@ -235,6 +237,187 @@ total++;
|
|
|
235
237
|
if (!(strongOK && weakHint)) failed++;
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
// 1.9.13: retro / insights / brainstorm
|
|
241
|
+
total++;
|
|
242
|
+
{
|
|
243
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-retro-'));
|
|
244
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
|
|
245
|
+
cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '캐시 helper', '--status', 'done', '--path', tmpR], { stdio: 'ignore', timeout: 10000 });
|
|
246
|
+
cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '인증 helper', '--status', 'in-progress', '--path', tmpR], { stdio: 'ignore', timeout: 10000 });
|
|
247
|
+
fs.appendFileSync(path.join(tmpR, '.harness/decisions.md'), `\n### 2026-05-13 — 캐시 차등 TTL 결정\n- Reason: ...\n`);
|
|
248
|
+
fs.appendFileSync(path.join(tmpR, '.harness/review-evidence.md'), `\n## 2026-05-13 verify-code\nexit=0 (250ms)\nexit=0 (180ms)\nexit=0 (120ms)\nexit=0 (90ms)\n`);
|
|
249
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'retro', tmpR], { encoding: 'utf8', timeout: 15000 });
|
|
250
|
+
const ok = r.status === 0 && /한 줄 요약/.test(r.stdout) && /작업 상태 분포/.test(r.stdout) && /다음 우선 작업/.test(r.stdout) && /검증 시간 추세/.test(r.stdout);
|
|
251
|
+
console.log(ok ? '✓ B(1.9.13) retro: 한 줄 요약 + 다음 우선 작업 + 검증 시간 추세' : '✗ retro 실패');
|
|
252
|
+
if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
total++;
|
|
256
|
+
{
|
|
257
|
+
const tmpI = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ins-'));
|
|
258
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpI, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
|
|
259
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'insights', tmpI], { encoding: 'utf8', timeout: 15000 });
|
|
260
|
+
const ok = r.status === 0 && /핵심 지표/.test(r.stdout) && /누적 task/.test(r.stdout);
|
|
261
|
+
console.log(ok ? '✓ B(1.9.13) insights: 핵심 지표 출력' : '✗ insights 실패');
|
|
262
|
+
if (!ok) failed++;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
total++;
|
|
266
|
+
{
|
|
267
|
+
const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-brain-'));
|
|
268
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
|
|
269
|
+
fs.appendFileSync(path.join(tmpB, '.harness/decisions.md'), `\n### 2026-05-13 — 캐시 차등 TTL 결정\n- Reason: open-meteo 응답 최적화\n`);
|
|
270
|
+
cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '캐시 helper 구현', '--path', tmpB], { stdio: 'ignore', timeout: 10000 });
|
|
271
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'brainstorm', '캐시', '--path', tmpB], { encoding: 'utf8', timeout: 15000 });
|
|
272
|
+
const ok = r.status === 0 && /Brainstorm — "캐시"/.test(r.stdout) && /관련 결정/.test(r.stdout) && /시작 전 권장 액션/.test(r.stdout);
|
|
273
|
+
console.log(ok ? '✓ B(1.9.13) brainstorm: 주제 매칭 + 시작 컨텍스트' : `✗ brainstorm 실패`);
|
|
274
|
+
if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
total++;
|
|
278
|
+
{
|
|
279
|
+
// session close 한 줄 요약 자동 출력
|
|
280
|
+
const tmpS = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-summary-'));
|
|
281
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpS, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
|
|
282
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpS], { encoding: 'utf8', timeout: 15000 });
|
|
283
|
+
const ok = r.status === 0 && /진행 요약/.test(r.stdout) && /자동 깊은 회고/.test(r.stdout);
|
|
284
|
+
console.log(ok ? '✓ B(1.9.13) session close: 한 줄 요약 자동 출력' : `✗ session close 요약 실패`);
|
|
285
|
+
if (!ok) { failed++; console.log(r.stdout.slice(-600)); }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
total++;
|
|
289
|
+
{
|
|
290
|
+
// 5세션 마일스톤 — 자동 깊은 회고
|
|
291
|
+
const tmpD = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-deep-'));
|
|
292
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpD, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
|
|
293
|
+
// 카운터를 4로 설정 → 다음 close가 5번째
|
|
294
|
+
fs.mkdirSync(path.join(tmpD, '.harness/cache'), { recursive: true });
|
|
295
|
+
fs.writeFileSync(path.join(tmpD, '.harness/cache/session-counter.json'), JSON.stringify({ count: 4 }));
|
|
296
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpD], { encoding: 'utf8', timeout: 15000 });
|
|
297
|
+
const ok = r.status === 0 && /5세션 마일스톤/.test(r.stdout) && /회고 \(retro\)/.test(r.stdout);
|
|
298
|
+
console.log(ok ? '✓ B(1.9.13) 5세션 마일스톤: 자동 깊은 회고' : '✗ 5세션 자동 회고 실패');
|
|
299
|
+
if (!ok) failed++;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 1.9.12: auto roadmap — install 직후 자동 생성
|
|
303
|
+
total++;
|
|
304
|
+
{
|
|
305
|
+
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto1-'));
|
|
306
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
307
|
+
const ok = fs.existsSync(path.join(tmpA, 'roadmap.html'));
|
|
308
|
+
console.log(ok ? '✓ B(1.9.12) install 직후 roadmap.html 자동 생성' : '✗ install 후 roadmap 없음');
|
|
309
|
+
if (!ok) failed++;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 1.9.12: session close 후 자동 갱신
|
|
313
|
+
total++;
|
|
314
|
+
{
|
|
315
|
+
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto2-'));
|
|
316
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
317
|
+
// 첫 mtime 캡처
|
|
318
|
+
const f = path.join(tmpA, 'roadmap.html');
|
|
319
|
+
const mt1 = fs.statSync(f).mtimeMs;
|
|
320
|
+
// 시간 차이 보장
|
|
321
|
+
const wait = Date.now() + 50; while (Date.now() < wait) {}
|
|
322
|
+
// 데이터 변경 + session close
|
|
323
|
+
cp.spawnSync(process.execPath, [CLI, 'task', 'add', '신규 작업', '--path', tmpA], { stdio: 'ignore' });
|
|
324
|
+
cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpA], { stdio: 'ignore' });
|
|
325
|
+
const mt2 = fs.statSync(f).mtimeMs;
|
|
326
|
+
const ok = mt2 > mt1;
|
|
327
|
+
console.log(ok ? '✓ B(1.9.12) session close 후 roadmap.html 자동 갱신 (mtime 증가)' : `✗ session close 후 갱신 안 됨 mt1=${mt1} mt2=${mt2}`);
|
|
328
|
+
if (!ok) failed++;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 1.9.12: roadmap auto off → 더 이상 자동 갱신 안 함
|
|
332
|
+
total++;
|
|
333
|
+
{
|
|
334
|
+
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto3-'));
|
|
335
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
336
|
+
cp.spawnSync(process.execPath, [CLI, 'roadmap', 'auto', 'off', '--path', tmpA], { stdio: 'ignore' });
|
|
337
|
+
const f = path.join(tmpA, 'roadmap.html');
|
|
338
|
+
const mt1 = fs.statSync(f).mtimeMs;
|
|
339
|
+
const wait = Date.now() + 80; while (Date.now() < wait) {}
|
|
340
|
+
cp.spawnSync(process.execPath, [CLI, 'task', 'add', '비활성 후 추가', '--path', tmpA], { stdio: 'ignore' });
|
|
341
|
+
cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpA], { stdio: 'ignore' });
|
|
342
|
+
const mt2 = fs.statSync(f).mtimeMs;
|
|
343
|
+
const ok = mt2 === mt1;
|
|
344
|
+
console.log(ok ? '✓ B(1.9.12) auto off: roadmap.html 갱신 안 됨' : `✗ auto off 후에도 갱신됨`);
|
|
345
|
+
if (!ok) failed++;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 1.9.12: --on-every-change 옵트인 시 task add만으로 갱신
|
|
349
|
+
total++;
|
|
350
|
+
{
|
|
351
|
+
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto4-'));
|
|
352
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
353
|
+
cp.spawnSync(process.execPath, [CLI, 'roadmap', 'auto', 'on', '--on-every-change', '--path', tmpA], { stdio: 'ignore' });
|
|
354
|
+
const f = path.join(tmpA, 'roadmap.html');
|
|
355
|
+
const mt1 = fs.statSync(f).mtimeMs;
|
|
356
|
+
const wait = Date.now() + 80; while (Date.now() < wait) {}
|
|
357
|
+
cp.spawnSync(process.execPath, [CLI, 'task', 'add', 'on-every-change 테스트', '--path', tmpA], { stdio: 'ignore' });
|
|
358
|
+
const mt2 = fs.statSync(f).mtimeMs;
|
|
359
|
+
const ok = mt2 > mt1;
|
|
360
|
+
console.log(ok ? '✓ B(1.9.12) --on-every-change: task add만으로 즉시 갱신' : `✗ on-every-change 미작동`);
|
|
361
|
+
if (!ok) failed++;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 1.9.12: status 출력
|
|
365
|
+
total++;
|
|
366
|
+
{
|
|
367
|
+
const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto5-'));
|
|
368
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
369
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'roadmap', 'auto', 'status', '--path', tmpA], { encoding: 'utf8' });
|
|
370
|
+
const ok = /enabled: true/.test(r.stdout) && /session-close.*✓/.test(r.stdout);
|
|
371
|
+
console.log(ok ? '✓ B(1.9.12) roadmap auto status: 상태 출력' : '✗ status 출력 실패');
|
|
372
|
+
if (!ok) failed++;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 1.9.11: roadmap 명령 통합 + 화이트보드/토큰/중앙정렬 회귀
|
|
376
|
+
total++;
|
|
377
|
+
{
|
|
378
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm-'));
|
|
379
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
380
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { encoding: 'utf8' });
|
|
381
|
+
const outFile = path.join(tmpR, 'roadmap.html');
|
|
382
|
+
const ok = r.status === 0 && fs.existsSync(outFile);
|
|
383
|
+
console.log(ok ? '✓ B(1.9.11) roadmap: 명령 + 파일 생성' : `✗ roadmap 실패\n${r.stdout}\n${r.stderr}`);
|
|
384
|
+
if (!ok) failed++;
|
|
385
|
+
}
|
|
386
|
+
total++;
|
|
387
|
+
{
|
|
388
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm2-'));
|
|
389
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
390
|
+
cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
|
|
391
|
+
const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
|
|
392
|
+
const ok = /화이트보드/.test(html) && /id="roadmap-svg"/.test(html) && /viewBox="0 0/.test(html) && /window\.lrZoom/.test(html) && /window\.lrReset/.test(html);
|
|
393
|
+
console.log(ok ? '✓ B(1.9.11) roadmap: 화이트보드 (panning/zoom JS)' : '✗ 화이트보드 부재');
|
|
394
|
+
if (!ok) failed++;
|
|
395
|
+
}
|
|
396
|
+
total++;
|
|
397
|
+
{
|
|
398
|
+
// 사용자 design-system 토큰 주입
|
|
399
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm3-'));
|
|
400
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
401
|
+
let ds = fs.readFileSync(path.join(tmpR, '.harness/design-system.md'), 'utf8');
|
|
402
|
+
ds = ds.replace('| color.primary | (실제 값으로 업데이트) | |', '| color.primary | #ff5722 | |');
|
|
403
|
+
fs.writeFileSync(path.join(tmpR, '.harness/design-system.md'), ds);
|
|
404
|
+
cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
|
|
405
|
+
const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
|
|
406
|
+
const ok = /--lr-primary: #ff5722/.test(html);
|
|
407
|
+
console.log(ok ? '✓ B(1.9.11) roadmap: design-system 토큰 자동 주입' : '✗ 토큰 주입 실패');
|
|
408
|
+
if (!ok) failed++;
|
|
409
|
+
}
|
|
410
|
+
total++;
|
|
411
|
+
{
|
|
412
|
+
// recommended에 project-roadmap-generator 자동 포함
|
|
413
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm4-'));
|
|
414
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
415
|
+
const skillsDir = path.join(tmpR, '.harness/skills/project-roadmap-generator');
|
|
416
|
+
const ok = fs.existsSync(skillsDir) && fs.existsSync(path.join(skillsDir, 'skill.json'));
|
|
417
|
+
console.log(ok ? '✓ B(1.9.11) recommended에 project-roadmap-generator 자동 설치' : '✗ 자동 설치 실패');
|
|
418
|
+
if (!ok) failed++;
|
|
419
|
+
}
|
|
420
|
+
|
|
238
421
|
// 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
|
|
239
422
|
total++;
|
|
240
423
|
{
|