leerness 1.8.0 → 1.9.0
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 +76 -0
- package/LICENSE +1 -1
- package/README.md +94 -56
- package/bin/harness.js +856 -240
- package/docs/PUBLISH_PRECHECK.md +93 -0
- package/package.json +26 -23
- package/scripts/e2e.js +92 -0
- package/docs/AX_CONSISTENCY_GUIDE.md +0 -9
- package/docs/AX_MIGRATION_GUIDE.md +0 -9
- package/docs/AX_PLAN_GUIDE.md +0 -9
- package/harness.js +0 -2
- package/skill-packs/ads-analytics/README.md +0 -6
- package/skill-packs/ai-verified-skill-publisher/README.md +0 -6
- package/skill-packs/appstore-review/README.md +0 -6
- package/skill-packs/commerce-api/README.md +0 -6
- package/skill-packs/crawling/README.md +0 -6
- package/skill-packs/firebase/README.md +0 -6
- package/skill-packs/office/README.md +0 -6
package/bin/harness.js
CHANGED
|
@@ -6,94 +6,38 @@ const path = require('path');
|
|
|
6
6
|
const cp = require('child_process');
|
|
7
7
|
const readline = require('readline');
|
|
8
8
|
|
|
9
|
-
const VERSION = '1.
|
|
9
|
+
const VERSION = '1.9.0';
|
|
10
10
|
const MARK = '<!-- leerness:managed -->';
|
|
11
11
|
const README_START = '<!-- leerness:project-readme:start -->';
|
|
12
12
|
const README_END = '<!-- leerness:project-readme:end -->';
|
|
13
13
|
|
|
14
14
|
const skillCatalog = {
|
|
15
|
-
'office':
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
},
|
|
22
|
-
'
|
|
23
|
-
displayNameKo: '커머스 API 연동 스킬 라이브러리',
|
|
24
|
-
version: '1.0.0',
|
|
25
|
-
lastUpdated: '2026-05-06',
|
|
26
|
-
verification: 'passed',
|
|
27
|
-
capabilities: ['쿠팡·롯데온·스마트스토어 API 연동 설계', '주문/상품/매출 동기화', '환경변수 기반 인증 분리', '레이트리밋/재시도/오류 처리']
|
|
28
|
-
},
|
|
29
|
-
'crawling': {
|
|
30
|
-
displayNameKo: '크롤링·브라우저 자동화 스킬 라이브러리',
|
|
31
|
-
version: '1.0.0',
|
|
32
|
-
lastUpdated: '2026-05-06',
|
|
33
|
-
verification: 'passed',
|
|
34
|
-
capabilities: ['Playwright 기반 자동화', '다운로드/로그인 세션 처리', '스크린샷 기반 실패 진단', '약관/권한/차단 위험 점검']
|
|
35
|
-
},
|
|
36
|
-
'firebase': {
|
|
37
|
-
displayNameKo: 'Firebase·Cloud Functions 스킬 라이브러리',
|
|
38
|
-
version: '1.0.0',
|
|
39
|
-
lastUpdated: '2026-05-06',
|
|
40
|
-
verification: 'passed',
|
|
41
|
-
capabilities: ['Firebase Functions 배포 구조', '환경변수/시크릿 분리', '권한/IAM 점검', '로컬 에뮬레이터 검증']
|
|
42
|
-
},
|
|
43
|
-
'ads-analytics': {
|
|
44
|
-
displayNameKo: '광고·GA4 분석 스킬 라이브러리',
|
|
45
|
-
version: '1.0.0',
|
|
46
|
-
lastUpdated: '2026-05-06',
|
|
47
|
-
verification: 'passed',
|
|
48
|
-
capabilities: ['GA4 이벤트/전환 점검', '광고 데이터 수집 구조화', '소스/매체 분석', '리포트 자동화']
|
|
49
|
-
},
|
|
50
|
-
'appstore-review': {
|
|
51
|
-
displayNameKo: '앱스토어 심사 대응 스킬 라이브러리',
|
|
52
|
-
version: '1.0.0',
|
|
53
|
-
lastUpdated: '2026-05-06',
|
|
54
|
-
verification: 'passed',
|
|
55
|
-
capabilities: ['심사 문구 분석', '개인정보 라벨 점검', '리젝 대응 초안', '웹뷰/앱 데이터 수집 구분']
|
|
56
|
-
},
|
|
57
|
-
'ai-verified-skill-publisher': {
|
|
58
|
-
displayNameKo: 'AI 검증 스킬 업로드·라이브러리화 스킬',
|
|
59
|
-
version: '1.0.0',
|
|
60
|
-
lastUpdated: '2026-05-06',
|
|
61
|
-
verification: 'passed',
|
|
62
|
-
capabilities: ['검증된 스킬 정규화', '민감정보 스캔', 'AI 검증 메타데이터 작성', 'npm/git 업로드 dry-run 및 실행 게이트']
|
|
63
|
-
}
|
|
15
|
+
'office': { displayNameKo: '마이크로소프트 오피스 자동화 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Word/Excel/PowerPoint 문서 자동화', '템플릿 기반 문서 생성', '표/차트/요약 문서화', '민감정보 제외 규칙 적용'] },
|
|
16
|
+
'commerce-api': { displayNameKo: '커머스 API 연동 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['쿠팡·롯데온·스마트스토어 API 연동 설계', '주문/상품/매출 동기화', '환경변수 기반 인증 분리', '레이트리밋/재시도/오류 처리'] },
|
|
17
|
+
'crawling': { displayNameKo: '크롤링·브라우저 자동화 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Playwright 기반 자동화', '다운로드/로그인 세션 처리', '스크린샷 기반 실패 진단', '약관/권한/차단 위험 점검'] },
|
|
18
|
+
'firebase': { displayNameKo: 'Firebase·Cloud Functions 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Firebase Functions 배포 구조', '환경변수/시크릿 분리', '권한/IAM 점검', '로컬 에뮬레이터 검증'] },
|
|
19
|
+
'ads-analytics': { displayNameKo: '광고·GA4 분석 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['GA4 이벤트/전환 점검', '광고 데이터 수집 구조화', '소스/매체 분석', '리포트 자동화'] },
|
|
20
|
+
'appstore-review': { displayNameKo: '앱스토어 심사 대응 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['심사 문구 분석', '개인정보 라벨 점검', '리젝 대응 초안', '웹뷰/앱 데이터 수집 구분'] },
|
|
21
|
+
'ai-verified-skill-publisher': { displayNameKo: 'AI 검증 스킬 업로드·라이브러리화 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['검증된 스킬 정규화', '민감정보 스캔', 'AI 검증 메타데이터 작성', 'npm/git 업로드 dry-run 및 실행 게이트'] },
|
|
22
|
+
'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] }
|
|
64
23
|
};
|
|
65
24
|
|
|
66
25
|
const routes = {
|
|
67
|
-
planning:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
},
|
|
79
|
-
release: {
|
|
80
|
-
read: ['.harness/plan.md', '.harness/release-checklist.md', '.harness/testing-strategy.md', '.harness/current-state.md', '.harness/leerness-maintenance.md'],
|
|
81
|
-
update: ['.harness/release-checklist.md', '.harness/progress-tracker.md', '.harness/task-log.md', '.harness/session-handoff.md']
|
|
82
|
-
},
|
|
83
|
-
migration: {
|
|
84
|
-
read: ['.harness/AX_MIGRATION_GUIDE.md', '.harness/protected-files.md', '.harness/context-routing.md', '.harness/writeback-policy.md'],
|
|
85
|
-
update: ['.harness/current-state.md', '.harness/task-log.md', '.harness/session-handoff.md']
|
|
86
|
-
},
|
|
87
|
-
'session-close': {
|
|
88
|
-
read: ['.harness/session-close-policy.md', '.harness/progress-tracker.md', '.harness/anti-lazy-work-policy.md', '.harness/plan.md'],
|
|
89
|
-
update: ['.harness/session-handoff.md', '.harness/progress-tracker.md', '.harness/current-state.md', '.harness/task-log.md']
|
|
90
|
-
},
|
|
91
|
-
'harness-maintenance': {
|
|
92
|
-
read: ['.harness/leerness-maintenance.md', '.harness/HARNESS_VERSION', '.harness/protected-files.md'],
|
|
93
|
-
update: ['.harness/task-log.md', '.harness/session-handoff.md']
|
|
94
|
-
}
|
|
26
|
+
planning: { read: ['.harness/plan.md','.harness/progress-tracker.md','.harness/project-brief.md','.harness/current-state.md','.harness/guideline.md'], update: ['.harness/plan.md','.harness/progress-tracker.md','.harness/current-state.md','.harness/session-handoff.md'] },
|
|
27
|
+
feature: { read: ['.harness/plan.md','.harness/current-state.md','.harness/architecture.md','.harness/context-map.md','.harness/feature-contracts.md','.harness/skills/feature-implementation/README.md','.harness/reuse-map.md'], update: ['.harness/progress-tracker.md','.harness/feature-contracts.md','.harness/current-state.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
28
|
+
consistency: { read: ['.harness/design-system.md','.harness/consistency-policy.md','.harness/reuse-map.md','.harness/context-map.md'], update: ['.harness/design-system.md','.harness/reuse-map.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
29
|
+
release: { read: ['.harness/plan.md','.harness/release-checklist.md','.harness/testing-strategy.md','.harness/current-state.md','.harness/leerness-maintenance.md'], update: ['.harness/release-checklist.md','.harness/progress-tracker.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
30
|
+
migration: { read: ['.harness/AX_MIGRATION_GUIDE.md','.harness/protected-files.md','.harness/context-routing.md','.harness/writeback-policy.md'], update: ['.harness/current-state.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
31
|
+
'session-close': { read: ['.harness/session-close-policy.md','.harness/progress-tracker.md','.harness/anti-lazy-work-policy.md','.harness/plan.md'], update: ['.harness/session-handoff.md','.harness/progress-tracker.md','.harness/current-state.md','.harness/task-log.md'] },
|
|
32
|
+
'session-start': { read: ['.harness/session-handoff.md','.harness/current-state.md','.harness/plan.md','.harness/progress-tracker.md','.harness/decisions.md','.harness/task-log.md'], update: ['.harness/current-state.md'] },
|
|
33
|
+
'harness-maintenance': { read: ['.harness/leerness-maintenance.md','.harness/HARNESS_VERSION','.harness/protected-files.md'], update: ['.harness/task-log.md','.harness/session-handoff.md'] },
|
|
34
|
+
bugfix: { read: ['.harness/plan.md','.harness/progress-tracker.md','.harness/decisions.md','.harness/feature-contracts.md','.harness/architecture.md'], update: ['.harness/progress-tracker.md','.harness/decisions.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
35
|
+
refactor: { read: ['.harness/plan.md','.harness/architecture.md','.harness/reuse-map.md','.harness/decisions.md'], update: ['.harness/architecture.md','.harness/reuse-map.md','.harness/decisions.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
36
|
+
research: { read: ['.harness/decisions.md','.harness/task-log.md','.harness/architecture.md','.harness/context-map.md'], update: ['.harness/decisions.md','.harness/task-log.md','.harness/current-state.md'] }
|
|
95
37
|
};
|
|
96
38
|
|
|
39
|
+
const STATUSES = ['requested','planned','in-progress','waiting','on-hold','blocked','incomplete','done','dropped'];
|
|
40
|
+
|
|
97
41
|
function log(s = '') { console.log(s); }
|
|
98
42
|
function ok(s) { log('✓ ' + s); }
|
|
99
43
|
function warn(s) { log('⚠ ' + s); }
|
|
@@ -101,8 +45,9 @@ function fail(s) { log('✗ ' + s); }
|
|
|
101
45
|
function absRoot(p) { return path.resolve(p || process.cwd()); }
|
|
102
46
|
function exists(p) { return fs.existsSync(p); }
|
|
103
47
|
function read(p) { return fs.readFileSync(p, 'utf8'); }
|
|
48
|
+
function readBuf(p) { return fs.readFileSync(p); }
|
|
104
49
|
function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
|
|
105
|
-
function
|
|
50
|
+
function writeUtf8(p, s) { mkdirp(path.dirname(p)); fs.writeFileSync(p, s, { encoding: 'utf8' }); }
|
|
106
51
|
function append(p, s) { mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
|
|
107
52
|
function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
|
|
108
53
|
function today() { return new Date().toISOString().slice(0, 10); }
|
|
@@ -111,7 +56,7 @@ function arg(name, def = null) { const i = process.argv.indexOf(name); return i
|
|
|
111
56
|
function has(name) { return process.argv.includes(name); }
|
|
112
57
|
function nonFlagArgs() {
|
|
113
58
|
const out = [];
|
|
114
|
-
const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--
|
|
59
|
+
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']);
|
|
115
60
|
const a = process.argv.slice(2);
|
|
116
61
|
for (let i = 0; i < a.length; i++) {
|
|
117
62
|
const x = a[i];
|
|
@@ -121,156 +66,132 @@ function nonFlagArgs() {
|
|
|
121
66
|
return out;
|
|
122
67
|
}
|
|
123
68
|
function detectProjectName(root) { try { const pkg = JSON.parse(read(path.join(root, 'package.json'))); if (pkg.name) return pkg.name; } catch {} return path.basename(root); }
|
|
124
|
-
function
|
|
125
|
-
const v = String(
|
|
69
|
+
function detectLanguageValue(root, value = 'auto') {
|
|
70
|
+
const v = String(value || 'auto').toLowerCase();
|
|
126
71
|
if (v === 'ko' || v === 'en') return v;
|
|
127
|
-
const candidates = ['README.md', 'docs/guideline.md', '.harness/project-brief.md'];
|
|
72
|
+
const candidates = ['README.md', 'docs/guideline.md', '.harness/project-brief.md', '.harness/plan.md'];
|
|
128
73
|
let text = '';
|
|
129
|
-
for (const c of candidates) { const p = path.join(root, c); if (exists(p)) text += read(p).slice(0,
|
|
74
|
+
for (const c of candidates) { const p = path.join(root, c); if (exists(p)) text += read(p).slice(0, 3000); }
|
|
130
75
|
return /[가-힣]/.test(text) ? 'ko' : 'en';
|
|
131
76
|
}
|
|
132
77
|
function fm(role, readWhen, updateWhen, body) {
|
|
133
78
|
return `---\nleernessRole: ${role}\nreadWhen:\n${readWhen.map(x => ' - ' + x).join('\n')}\nupdateWhen:\n${updateWhen.map(x => ' - ' + x).join('\n')}\ndoNotStore:\n - 실제 토큰\n - 비밀번호\n - 운영 쿠키\n - 민감한 개인정보 원문\n---\n${MARK}\n${body}`;
|
|
134
79
|
}
|
|
80
|
+
function ask(question) {
|
|
81
|
+
return new Promise(resolve => {
|
|
82
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
83
|
+
rl.question(question, answer => { rl.close(); resolve(String(answer || '').trim()); });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
135
87
|
function managedReadmeBlock(project) {
|
|
136
88
|
return [
|
|
137
89
|
README_START,
|
|
138
90
|
'## Leerness Project Harness',
|
|
139
91
|
'',
|
|
140
|
-
|
|
92
|
+
`이 프로젝트는 Leerness v${VERSION} 하네스를 사용합니다. AI 에이전트는 작업 전 \`leerness handoff\`로 컨텍스트를 적재하고, 작업 후 \`leerness check\`/\`leerness audit\`/\`leerness session close\`를 수행해야 합니다.`,
|
|
141
93
|
'',
|
|
142
94
|
'### Core Commands',
|
|
143
95
|
'',
|
|
144
96
|
'```bash',
|
|
145
|
-
'leerness
|
|
146
|
-
'leerness
|
|
147
|
-
'leerness
|
|
148
|
-
'leerness
|
|
149
|
-
'leerness
|
|
97
|
+
'leerness handoff . # 세션 시작 컨텍스트 자동 로드',
|
|
98
|
+
'leerness status . # 설치 상태',
|
|
99
|
+
'leerness verify . # 필수 파일 검증',
|
|
100
|
+
'leerness audit . # 일관성·계획-진행 정렬 감사',
|
|
101
|
+
'leerness scan secrets . # 시크릿 패턴 스캔',
|
|
102
|
+
'leerness encoding check . # UTF-8 / BOM / CRLF 검사',
|
|
103
|
+
'leerness lazy detect . # 게으름 방지 자동 평가',
|
|
104
|
+
'leerness memory search "키" # 결정/이력 검색',
|
|
105
|
+
'leerness session close . # 세션 종료 + handoff 자동 작성',
|
|
106
|
+
'leerness update . # 자동 버전 감지 + 마이그레이션',
|
|
150
107
|
'```',
|
|
151
108
|
'',
|
|
152
|
-
'###
|
|
109
|
+
'### Planning Files',
|
|
153
110
|
'',
|
|
154
111
|
'- `.harness/plan.md`: 전체 목표, milestone, 제외/드랍 범위',
|
|
155
112
|
'- `.harness/progress-tracker.md`: 요청 단위 상태와 증거',
|
|
156
113
|
'- `.harness/current-state.md`: 지금 이어서 할 작업',
|
|
157
|
-
'- `.harness/session-handoff.md`: 다음 세션 인수인계',
|
|
114
|
+
'- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)',
|
|
158
115
|
'',
|
|
159
116
|
`Last synced by Leerness v${VERSION}: ${today()}`,
|
|
160
117
|
README_END,
|
|
161
118
|
''
|
|
162
119
|
].join('\n');
|
|
163
120
|
}
|
|
164
|
-
|
|
121
|
+
|
|
122
|
+
function mergeReadmeSection(existing, block) {
|
|
165
123
|
if (!existing) return `# Project\n\n${block}`;
|
|
166
124
|
const s = existing.indexOf(README_START); const e = existing.indexOf(README_END);
|
|
167
125
|
if (s >= 0 && e >= s) return existing.slice(0, s).trimEnd() + '\n\n' + block + '\n' + existing.slice(e + README_END.length).trimStart();
|
|
168
126
|
return existing.trimEnd() + '\n\n' + block;
|
|
169
127
|
}
|
|
128
|
+
|
|
170
129
|
function skillLock(skills) {
|
|
171
130
|
const data = { leernessVersion: VERSION, updatedAt: now(), installedSkills: {} };
|
|
172
131
|
for (const s of skills) data.installedSkills[s] = skillCatalog[s] || { version: 'unknown' };
|
|
173
132
|
return JSON.stringify(data, null, 2) + '\n';
|
|
174
133
|
}
|
|
134
|
+
|
|
175
135
|
function coreFiles(root, lang = 'ko', selectedSkills = []) {
|
|
176
136
|
const project = detectProjectName(root);
|
|
177
137
|
const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
|
|
178
138
|
return {
|
|
179
|
-
'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## Mandatory read order\n1. .harness/context-routing.md\n2. .harness/
|
|
180
|
-
'CLAUDE.md': `${MARK}\n# Claude Code Instructions\n\nFollow AGENTS.md.
|
|
181
|
-
'.cursor/rules/leerness.mdc': `${MARK}\n---\nalwaysApply: true\n---\nFollow AGENTS.md and .harness/context-routing.md
|
|
182
|
-
'.github/copilot-instructions.md': `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .harness/ as project memory
|
|
139
|
+
'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\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다.\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`,
|
|
140
|
+
'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`,
|
|
141
|
+
'.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`,
|
|
142
|
+
'.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`,
|
|
183
143
|
'.harness/HARNESS_VERSION': VERSION + '\n',
|
|
184
144
|
'.harness/LANGUAGE': lang + '\n',
|
|
185
145
|
'.harness/manifest.json': JSON.stringify({ project, leernessVersion: VERSION, language: lang, installedAt: now() }, null, 2) + '\n',
|
|
186
146
|
'.harness/skills-lock.json': skillLock(selectedSkills),
|
|
187
|
-
'.harness/project-brief.md': fm('project-brief', ['프로젝트 목적 확인',
|
|
188
|
-
'.harness/plan.md': fm('plan', ['작업 시작 전',
|
|
189
|
-
'.harness/progress-tracker.md': fm('progress-tracker', ['세션 시작',
|
|
190
|
-
'.harness/guideline.md': fm('guideline', ['구현 전 품질 기준 확인',
|
|
191
|
-
'.harness/plan-progress-boundary.md': fm('plan-progress-boundary', ['계획과 진행률이 중복될 때',
|
|
192
|
-
'.harness/current-state.md': fm('current-state', ['세션 시작',
|
|
193
|
-
'.harness/context-routing.md': fm('context-routing', ['모든 작업 전',
|
|
194
|
-
'.harness/writeback-policy.md': fm('writeback-policy', ['작업 완료 전',
|
|
195
|
-
'.harness/task-type-map.md': fm('task-type-map', ['사용자 요청 분류'], ['작업 유형 추가'], `# Task Type Map\n\n| User Request | Task Type | Route |\n|---|---|---|\n| 계획 세워줘 | planning | leerness route planning |\n| 기능 구현 | feature | leerness route feature |\n| 디자인 통일 | consistency | leerness route consistency |\n| 배포 | release | leerness route release |\n| 마이그레이션 | migration | leerness route migration |\n| 세션 종료 | session-close | leerness route session-close |\n`),
|
|
196
|
-
'.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- README.md Leerness managed section\n\nUse merge, archive, or deprecated markers instead of deletion.\n`),
|
|
197
|
-
'.harness/architecture.md': fm('architecture', ['기능 구현',
|
|
198
|
-
'.harness/context-map.md': fm('context-map', ['관련 파일 탐색',
|
|
147
|
+
'.harness/project-brief.md': fm('project-brief', ['프로젝트 목적 확인','신규 기능 판단','계획 수립'], ['프로젝트 목적 변경','사용자/범위 변경'], `# Project Brief\n\n## Project\n${project}\n\n## Purpose\n- 이 프로젝트의 목적을 실제 내용으로 업데이트하세요.\n\n## Users\n-\n\n## Success Criteria\n-\n`),
|
|
148
|
+
'.harness/plan.md': fm('plan', ['작업 시작 전','새 요청 접수','범위 변경','신규 프로젝트 감지'], ['계획 추가/수정/드랍','milestone 변경','목표 변경'], `# Plan\n\n## Goal\n- 사용자 목적을 기준으로 전체 계획을 유지합니다.\n\n## Scope\n- 포함 범위를 기록합니다.\n\n## Out of Scope / Dropped\n| ID | Item | Reason | Date |\n|---|---|---|---|\n\n## Milestones\n\n### M-0001. 프로젝트 계획 정리\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] project-brief.md를 실제 프로젝트 목적에 맞게 작성\n- [ ] context-map.md를 실제 파일 구조에 맞게 작성\n`),
|
|
149
|
+
'.harness/progress-tracker.md': fm('progress-tracker', ['세션 시작','세션 종료','사용자 요청 상태 확인'], ['작업 상태 변경','검증 결과 추가','사용자 요청 드랍'], `# Progress Tracker\n\nStatus values: requested, planned, in-progress, waiting, on-hold, blocked, incomplete, done, dropped\n\n| ID | Status | Request | Evidence | Next Action | Updated |\n|---|---|---|---|---|---|\n`),
|
|
150
|
+
'.harness/guideline.md': fm('guideline', ['구현 전 품질 기준 확인','계획 이행 기준 확인'], ['개발 기준 변경','검증 루틴 변경'], `# Guideline\n\n## Operating Principle\n- plan.md의 목표와 범위를 기준으로 작업합니다.\n- progress-tracker.md의 요청 상태를 기준으로 완료/미완료를 판단합니다.\n- guideline.md에는 진행률 수치를 직접 기록하지 않습니다. 진행률은 plan.md/progress-tracker.md가 단일 출처입니다.\n\n## Quality Gate\n- 변경 전 관련 route를 확인합니다 (\`leerness route <task-type>\`).\n- 변경 후 \`leerness verify\`, \`leerness audit\`, \`leerness check\`을 실행합니다.\n- 완료 선언 전 \`leerness lazy detect\`을 실행합니다.\n- 세션 종료 시 \`leerness session close\`를 실행합니다.\n`),
|
|
151
|
+
'.harness/plan-progress-boundary.md': fm('plan-progress-boundary', ['계획과 진행률이 중복될 때','작업 추적 구조 변경'], ['역할 분리 기준 변경'], `# Plan / Progress Boundary\n\n## plan.md\n- 전체 목표, milestone, 포함/제외 범위, 계획 변경 이력.\n\n## progress-tracker.md\n- 사용자 요청 단위의 상태, 증거, 다음 액션.\n- ID 규칙: T-0001부터 단조 증가. plan add 시 부여되는 ID는 plan/progress 양쪽에서 고유합니다.\n\n## guideline.md\n- plan/progress를 수행할 때 지켜야 할 실행 기준.\n`),
|
|
152
|
+
'.harness/current-state.md': fm('current-state', ['세션 시작','작업 이어받기'], ['현재 상태 변경','다음 작업 변경'], `# Current State\n\nUpdated: ${today()}\n\n## Now\n-\n\n## Next\n-\n\n## Blockers\n-\n`),
|
|
153
|
+
'.harness/context-routing.md': fm('context-routing', ['모든 작업 전','작업 유형 판단'], ['새 작업 유형 추가','참조 파일 변경'], `# Context Routing\n\n${Object.entries(routes).map(([k, v]) => `## ${k}\nRead:\n${v.read.map(x => '- ' + x).join('\n')}\n\nUpdate:\n${v.update.map(x => '- ' + x).join('\n')}`).join('\n\n')}\n`),
|
|
154
|
+
'.harness/writeback-policy.md': fm('writeback-policy', ['작업 완료 전','문서 갱신 판단'], ['기록 대상 변경'], `# Writeback Policy\n\n- plan.md: 사용자 목적, milestone, 범위 추가/제외\n- progress-tracker.md: 요청 단위 상태와 증거 (in-place 갱신)\n- current-state.md: 현재 상태와 다음 작업\n- task-log.md: 수행 이력 (자동 추가)\n- session-handoff.md: 다음 세션 인수인계 (\`session close\`가 자동 작성)\n- decisions.md: 되돌리기 어려운 결정\n- design-system.md: UI/UX/컴포넌트 기준\n- feature-contracts.md: 입력/출력/상태/오류 계약\n- review-evidence.md: 검증 결과 (자동 누적)\n`),
|
|
155
|
+
'.harness/task-type-map.md': fm('task-type-map', ['사용자 요청 분류'], ['작업 유형 추가'], `# Task Type Map\n\n| User Request | Task Type | Route |\n|---|---|---|\n| 계획 세워줘 / 로드맵 짜줘 | planning | leerness route planning |\n| 기능 구현 / 만들어줘 | feature | leerness route feature |\n| 버그 수정 / 고쳐줘 | bugfix | leerness route bugfix |\n| 리팩토링 / 정리 | refactor | leerness route refactor |\n| 리서치 / 비교/조사 | research | leerness route research |\n| 디자인 통일 / 일관성 | consistency | leerness route consistency |\n| 배포 / 릴리즈 | release | leerness route release |\n| 마이그레이션 | migration | leerness route migration |\n| 세션 시작 / 이어 작업 | session-start | leerness route session-start |\n| 세션 종료 | session-close | leerness route session-close |\n`),
|
|
156
|
+
'.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`),
|
|
157
|
+
'.harness/architecture.md': fm('architecture', ['기능 구현','리팩토링','마이그레이션'], ['구조 변경'], `# Architecture\n\n## Overview\n- 실제 구조를 기록하세요.\n\n## Data Flow\n-\n\n## External Dependencies\n-\n`),
|
|
158
|
+
'.harness/context-map.md': fm('context-map', ['관련 파일 탐색','기능 구현 전'], ['파일 구조 변경'], `# Context Map\n\n| Area | Files | Notes |\n|---|---|---|\n| App | src/** | 실제 경로로 업데이트 |\n| Tests | tests/** | 검증 경로 |\n`),
|
|
199
159
|
'.harness/decisions.md': fm('decisions', ['설계 결정 확인'], ['중요 결정 발생'], `# Decisions\n\n## Template\n### ${today()} — Decision\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n`),
|
|
200
|
-
'.harness/task-log.md': fm('task-log', ['작업 이력 확인'], ['모든 의미 있는 작업 후'], `# Task Log\n\n## ${today()}\n- Leerness v${VERSION} initialized
|
|
201
|
-
'.harness/guardrails.md': fm('guardrails', ['모든 작업 전',
|
|
202
|
-
'.harness/design-system.md': fm('design-system', ['UI 변경',
|
|
203
|
-
'.harness/consistency-policy.md': fm('consistency-policy', ['UI/기능 중복 생성 전',
|
|
204
|
-
'.harness/reuse-map.md': fm('reuse-map', ['새 컴포넌트/API/helper 생성 전',
|
|
205
|
-
'.harness/feature-contracts.md': fm('feature-contracts', ['기능 구현/수정 전'], ['기능 입출력/상태/오류 변경'], `# Feature Contracts\n\n## Template\n- Feature:\n- Input:\n- Output:\n- States:\n- Errors:\n- Related files:\n`),
|
|
206
|
-
'.harness/testing-strategy.md': fm('testing-strategy', ['검증 전',
|
|
207
|
-
'.harness/review-checklist.md': fm('review-checklist', ['PR/리뷰 전'], ['리뷰 기준 변경'], `# Review Checklist\n\n- [ ] 계획과
|
|
208
|
-
'.harness/release-checklist.md': fm('release-checklist', ['배포 전'], ['배포 조건/환경변수/롤백 변경'], `# Release Checklist\n\n- [ ]
|
|
209
|
-
'.harness/session-close-policy.md': fm('session-close-policy', ['세션 종료 전'], ['세션 종료 형식 변경'], `# Session Close Policy\n\nEvery session must list:\n- Completed\n- In progress\n- Incomplete\n- Planned\n- Waiting\n- On hold\n- Blocked\n- Dropped\n- Verification\n- Recommended next direction\n- Next exact step\n`),
|
|
210
|
-
'.harness/anti-lazy-work-policy.md': fm('anti-lazy-work-policy', ['완료 선언 전'], ['게으른 작업 방지 기준 변경'], `# Anti Lazy Work Policy\n\
|
|
211
|
-
'.harness/session-handoff.md': fm('session-handoff', ['세션 시작',
|
|
212
|
-
'.harness/leerness-maintenance.md': fm('leerness-maintenance', ['작업 시작',
|
|
213
|
-
'.harness/language-policy.md': fm('language-policy', ['문서 작성 전'], ['언어 변경'], `# Language Policy\n\nSelected language: ${lang}\n\
|
|
214
|
-
'.harness/secret-policy.md': fm('secret-policy', ['스킬/배포/설정 변경 전'], ['민감정보 정책 변경'], `# Secret Policy\n\
|
|
215
|
-
'.harness/
|
|
216
|
-
'.harness/
|
|
160
|
+
'.harness/task-log.md': fm('task-log', ['작업 이력 확인'], ['모든 의미 있는 작업 후'], `# Task Log\n\n## ${today()}\n- Leerness v${VERSION} initialized.\n`),
|
|
161
|
+
'.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`),
|
|
162
|
+
'.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`),
|
|
163
|
+
'.harness/consistency-policy.md': fm('consistency-policy', ['UI/기능 중복 생성 전','재사용 판단'], ['일관성 정책 변경'], `# Consistency Policy\n\n동일한 기능을 하는 요소는 새로 만들기 전에 기존 구현을 찾아 재사용/확장/연결합니다.\n\n## Recursive Reuse Rule\n1. 같은 기능의 기존 요소를 찾습니다.\n2. 자기 참조/기저 규칙/재귀 흐름이 필요한지 확인합니다.\n3. 기존 요소를 재사용하거나 확장합니다.\n4. 불가피하게 새로 만들면 reuse-map.md에 이유를 기록합니다.\n\n## Audit Trigger\n\`leerness audit\`는 다음을 검사합니다:\n- 디자인 가이드 중복 파일\n- design-system.md 토큰 미정의\n- reuse-map.md 비어있음 + 컴포넌트/유틸 ≥3개 발견\n- plan vs progress 정렬\n`),
|
|
164
|
+
'.harness/reuse-map.md': fm('reuse-map', ['새 컴포넌트/API/helper 생성 전','중복 기능 감지'], ['재사용 가능한 요소 추가'], `# Reuse Map\n\n| Capability | Existing Element | Reuse Method | Notes |\n|---|---|---|---|\n`),
|
|
165
|
+
'.harness/feature-contracts.md': fm('feature-contracts', ['기능 구현/수정 전'], ['기능 입출력/상태/오류 변경'], `# Feature Contracts\n\n## Template\n- Feature:\n- Input:\n- Output:\n- States:\n- Errors:\n- Related files:\n- Test evidence ID:\n`),
|
|
166
|
+
'.harness/testing-strategy.md': fm('testing-strategy', ['검증 전','릴리즈 전'], ['테스트 전략 변경'], `# Testing Strategy\n\n- Typecheck (\`tsc --noEmit\` 또는 동등)\n- Lint (\`npm run lint\` 등)\n- Unit/Integration/E2E\n- Manual smoke test\n- Browser/UI smoke (frontend 변경 시)\n\n## Evidence Format\nEach completed task must reference an evidence ID stored in .harness/review-evidence.md.\n`),
|
|
167
|
+
'.harness/review-checklist.md': fm('review-checklist', ['PR/리뷰 전'], ['리뷰 기준 변경'], `# Review Checklist\n\n- [ ] 계획과 정렬되어 있는가\n- [ ] progress-tracker가 갱신되었는가\n- [ ] 보호 파일을 삭제하지 않았는가\n- [ ] 디자인/기능 재사용을 확인했는가\n- [ ] 시크릿이 코드에 들어가지 않았는가 (\`leerness scan secrets\`)\n- [ ] 한글 인코딩 OK (\`leerness encoding check\`)\n- [ ] 게으름 평가 통과 (\`leerness lazy detect\`)\n`),
|
|
168
|
+
'.harness/release-checklist.md': fm('release-checklist', ['배포 전'], ['배포 조건/환경변수/롤백 변경'], `# Release Checklist\n\n- [ ] \`leerness verify .\`\n- [ ] \`leerness audit .\`\n- [ ] \`leerness scan secrets .\`\n- [ ] \`leerness encoding check .\`\n- [ ] 프로젝트 typecheck/lint/test\n- [ ] 환경변수 (.env.example) 동기화\n- [ ] 롤백 방법 확인\n- [ ] CHANGELOG 갱신\n`),
|
|
169
|
+
'.harness/session-close-policy.md': fm('session-close-policy', ['세션 종료 전'], ['세션 종료 형식 변경'], `# Session Close Policy\n\nEvery session must list:\n- Completed\n- In progress\n- Incomplete\n- Planned\n- Waiting\n- On hold\n- Blocked\n- Dropped\n- Verification (commands run, results)\n- Recommended next direction\n- Next exact step\n\n\`leerness session close\`가 위 9개 카테고리를 자동 추출하고, session-handoff.md에 다음 세션을 위한 인수인계 블록을 자동 작성합니다.\n`),
|
|
170
|
+
'.harness/anti-lazy-work-policy.md': fm('anti-lazy-work-policy', ['완료 선언 전'], ['게으른 작업 방지 기준 변경'], `# Anti Lazy Work Policy\n\n## Rules\n1. **증거 없는 완료 금지**: \"완료\"를 선언하려면 progress-tracker의 evidence 컬럼에 명령 출력/테스트 결과/스크린샷 경로 등이 있어야 합니다.\n2. **빈 핸드오프 금지**: 세션 종료 시 session-handoff.md의 Completed/In Progress/Next Exact Step이 모두 비어 있으면 close가 \"insufficient\" 상태로 표시됩니다.\n3. **부분 구현 자기보고**: 완전 구현이 아니면 status를 \`incomplete\`로, Next Exact Step에 \"무엇을 추가해야 끝나는지\" 한 줄을 적습니다.\n4. **검증 기록**: typecheck/lint/test 결과를 review-evidence.md에 누적 기록합니다.\n5. **TODO 표지**: 코드에 \`TODO\`/\`FIXME\`/\`XXX\`를 새로 도입하면 progress-tracker에 동일 ID로 추적합니다.\n6. **거짓 완료 자동 감지**: \`leerness lazy detect\`는 다음을 자동 점검합니다.\n - progress-tracker에 done인데 evidence가 비어있는 row\n - session-handoff의 Completed가 비어있고 Next Exact Step도 비어있음\n - 코드에 새 TODO/FIXME 추가 + progress-tracker에 추적 항목 없음\n - test 명령 실행 흔적 없음 (review-evidence.md 또는 task-log.md에 명령 기록)\n`),
|
|
171
|
+
'.harness/session-handoff.md': fm('session-handoff', ['세션 시작','다음 작업 이어받기'], ['세션 종료'], `# Session Handoff\n\nLast generated: (자동)\n\n## Completed\n-\n\n## In Progress\n-\n\n## Incomplete / Waiting / On Hold / Blocked\n-\n\n## Dropped\n-\n\n## Verification\n-\n\n## Recommended Direction\n-\n\n## Next Exact Step\n-\n`),
|
|
172
|
+
'.harness/leerness-maintenance.md': fm('leerness-maintenance', ['작업 시작','마이그레이션/릴리즈 전'], ['버전 정책 변경'], `# Leerness Maintenance\n\nAI agents should check:\n\n\`\`\`bash\nleerness --version\nleerness self check .\nleerness update --check # 24h 캐시 자동 감지\nleerness update --yes # 새 버전 발견 시 자동 마이그레이션\ncat .harness/HARNESS_VERSION\nnpm view leerness version\n\`\`\`\n`),
|
|
173
|
+
'.harness/language-policy.md': fm('language-policy', ['문서 작성 전'], ['언어 변경'], `# Language Policy\n\nSelected language: ${lang}\n\n모든 Leerness 노트, 스킬 노트, 세션 보고, 작업 목록은 위 언어를 기본으로 사용합니다 (사용자가 다른 언어를 명시 요청 시 예외).\n`),
|
|
174
|
+
'.harness/secret-policy.md': fm('secret-policy', ['스킬/배포/설정 변경 전'], ['민감정보 정책 변경'], `# Secret Policy\n\n## Rules\n- 환경변수 이름만 기록하고 값은 .env.local, CI secrets, 클라우드 시크릿 매니저로 옮깁니다.\n- 코드/문서/로그에 토큰/비밀번호/쿠키/주민번호/카드번호 등을 평문으로 두지 않습니다.\n- 변경 전 \`leerness scan secrets .\`을 실행해 흔적을 확인합니다.\n\n## Patterns scanned\n- AWS Access Key (\`AKIA[0-9A-Z]{16}\`)\n- GitHub PAT (\`ghp_[A-Za-z0-9]{36}\`)\n- OpenAI key (\`sk-[A-Za-z0-9]{20,}\`)\n- Anthropic key (\`sk-ant-[A-Za-z0-9-]{20,}\`)\n- Google API key, Slack token, generic private key, hardcoded password\n`),
|
|
175
|
+
'.harness/encoding-policy.md': fm('encoding-policy', ['파일 생성 전','한글 깨짐 보고','배포 전'], ['인코딩 정책 변경'], `# Encoding Policy\n\n## Rules\n- 모든 텍스트 파일은 **BOM 없는 UTF-8**.\n- Windows .bat 최상단에 \`chcp 65001 >nul\`.\n- PowerShell .ps1 시작에 \`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\`.\n- Python 파일은 첫 줄에 \`# -*- coding: utf-8 -*-\` (Python 2 호환 필요 시).\n- LF 라인 엔딩 권장 (Windows에서도 .gitattributes로 통일).\n\n## Auto check\n\`leerness encoding check\`는 BOM, NUL, .bat의 chcp 65001, 한글 라운드트립을 검사합니다.\n`),
|
|
176
|
+
'.harness/test-evidence-policy.md': fm('test-evidence-policy', ['검증 결과 기록 시'], ['검증 형식 변경'], `# Test Evidence Policy\n\n매 검증은 \`.harness/review-evidence.md\`에 누적 기록합니다.\n\n## Format\n\`\`\`\n## YYYY-MM-DD HH:MM\nTask: T-XXXX\nCommand: <명령>\nExit: <코드>\nNote: <주요 결과 요약>\nArtifacts: <스크린샷/로그 경로>\n\`\`\`\n`),
|
|
177
|
+
'.harness/review-evidence.md': fm('review-evidence', ['진행 보고','릴리즈 검토'], ['검증 결과 기록'], `# Review Evidence\n\nVerification command/result history. Append-only.\n`),
|
|
178
|
+
'.harness/AX_PLAN_GUIDE.md': fm('ax-plan-guide', ['계획 수립/변경','신규 프로젝트'], ['계획 가이드 변경'], `# AX Plan Guide\n\n1. 사용자 요청이 기존 plan.md 범위 내인지 확인합니다.\n2. 새 범위라면 plan.md(milestone)와 progress-tracker.md(T-id) 양쪽에 추가합니다.\n3. 사용자가 범위를 드랍하면 삭제 대신 dropped 표기를 추가합니다.\n4. 신규 프로젝트는 코딩 전에 plan.md/project-brief.md를 채웁니다.\n`),
|
|
179
|
+
'.harness/AX_MIGRATION_GUIDE.md': fm('ax-migration-guide', ['마이그레이션 전'], ['마이그레이션 정책 변경'], `# AX Migration Guide\n\n- Back up before changes (\`.harness/archive/\`).\n- 기존 프로젝트 메모리 보존 (preserve-by-default).\n- .env.example/.gitignore는 라인 단위 머지.\n- 보호 파일을 삭제하지 않습니다.\n- 마이그레이션 보고서는 \`.harness/migration-report.md\`.\n- 자동: \`leerness update --yes\`가 위 절차를 백업·머지·검증까지 한번에 수행합니다.\n`),
|
|
217
180
|
'.harness/AX_NEW_PROJECT_GUIDE.md': fm('ax-new-project-guide', ['신규 프로젝트 감지'], ['신규 설치 정책 변경'], `# AX New Project Guide\n\nBefore coding, ask or infer the project goal, users, scope, out-of-scope, stack, deployment target, and milestones. Then fill plan.md and project-brief.md.\n`),
|
|
218
181
|
'.harness/AX_SKILL_LIBRARY_GUIDE.md': fm('ax-skill-library-guide', ['스킬 학습/검증/업로드'], ['스킬 정책 변경'], `# AX Skill Library Guide\n\nValidated skills require metadata, sensitive data scan, AI verification, dry-run publish, and explicit execute approval.\n`),
|
|
219
182
|
'.harness/skill-index.md': fm('skill-index', ['작업별 스킬 선택'], ['스킬 추가/삭제'], `# Skill Index\n\n| ID | Korean Name | Capabilities | Last Updated | Verification |\n|---|---|---|---|---|\n${skillRows}\n`),
|
|
220
183
|
'.harness/templates/end-of-session-report.md': `# End of Session Report\n\n## Completed\n\n## In Progress\n\n## Incomplete\n\n## Planned\n\n## Waiting\n\n## On Hold\n\n## Blocked\n\n## Dropped\n\n## Verification\n\n## Recommended Direction\n\n## Next Exact Step\n`,
|
|
221
|
-
'.harness/templates/decision.md': '# Decision\n\n## Decision\n\n## Reason\n\n## Alternatives\n\n## Impact\n'
|
|
184
|
+
'.harness/templates/decision.md': '# Decision\n\n## Decision\n\n## Reason\n\n## Alternatives\n\n## Impact\n',
|
|
185
|
+
'.harness/templates/task-row.md': `# Task Row Template\n\n| ID | Status | Request | Evidence | Next Action | Updated |\n| T-XXXX | requested | <request> | <evidence-id or empty> | <next> | YYYY-MM-DD |\n`,
|
|
186
|
+
'.claude/commands/handoff.md': `# /handoff\n\n현재 프로젝트의 컨텍스트를 적재합니다.\n\n\`\`\`\n!leerness handoff .\n\`\`\`\n`,
|
|
187
|
+
'.claude/commands/session-close.md': `# /session-close\n\n세션 종료 보고를 자동 생성하고 session-handoff.md를 갱신합니다.\n\n\`\`\`\n!leerness session close .\n\`\`\`\n`,
|
|
188
|
+
'.claude/commands/audit.md': `# /audit\n\n계획-진행 정렬, 디자인/재사용 일관성, 시크릿/인코딩을 일괄 점검합니다.\n\n\`\`\`\n!leerness audit .\n!leerness scan secrets .\n!leerness encoding check .\n\`\`\`\n`,
|
|
189
|
+
'.claude/commands/lazy-detect.md': `# /lazy-detect\n\n게으름 방지 자동 평가를 실행합니다.\n\n\`\`\`\n!leerness lazy detect .\n\`\`\`\n`,
|
|
190
|
+
'.claude/commands/update.md': `# /update\n\nleerness 자동 업데이트를 실행합니다 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n`,
|
|
191
|
+
'.claude/skills/leerness.md': `---\nname: leerness\ndescription: Leerness harness commands - handoff, audit, scan secrets, encoding check, lazy detect, session close, update. Use when the user asks to load project context, verify work quality, scan secrets, check encoding, or end a session.\n---\n\n# leerness skill\n\n## When to use\n- 사용자가 프로젝트 컨텍스트를 로드해달라고 할 때\n- 완료 선언 전 자기 검증을 요청할 때\n- 세션을 종료하거나 인수인계를 요청할 때\n- 시크릿/한글 인코딩 점검을 요청할 때\n- 새 leerness 버전 적용을 요청할 때\n\n## Commands\n\n\`\`\`bash\nleerness handoff . # 컨텍스트 로드\nleerness check . # pre-action 체크\nleerness audit . # 일관성/계획 정렬 감사\nleerness scan secrets . # 시크릿 패턴 스캔\nleerness encoding check . # UTF-8/BOM/CRLF\nleerness lazy detect . # 게으름 평가\nleerness memory search "key" # 결정/이력 검색\nleerness session close . # 종료 보고 + handoff 자동 생성\nleerness update --yes # 자동 업데이트\n\`\`\`\n`,
|
|
222
192
|
};
|
|
223
193
|
}
|
|
224
|
-
|
|
225
|
-
if (!v || v === true) return [];
|
|
226
|
-
if (v === 'all') return Object.keys(skillCatalog);
|
|
227
|
-
if (v === 'recommended') return ['office', 'commerce-api', 'ai-verified-skill-publisher'];
|
|
228
|
-
return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => skillCatalog[s]);
|
|
229
|
-
}
|
|
230
|
-
function parseSkills() { return parseSkillsValue(arg('--skills', '')); }
|
|
231
|
-
function detectLanguageValue(root, value = 'auto') {
|
|
232
|
-
const v = String(value || 'auto').toLowerCase();
|
|
233
|
-
if (v === 'ko' || v === 'en') return v;
|
|
234
|
-
const candidates = ['README.md', 'docs/guideline.md', '.harness/project-brief.md', '.harness/plan.md'];
|
|
235
|
-
let text = '';
|
|
236
|
-
for (const c of candidates) { const p = path.join(root, c); if (exists(p)) text += read(p).slice(0, 3000); }
|
|
237
|
-
return /[가-힣]/.test(text) ? 'ko' : 'en';
|
|
238
|
-
}
|
|
239
|
-
function ask(question) {
|
|
240
|
-
return new Promise(resolve => {
|
|
241
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
242
|
-
rl.question(question, answer => { rl.close(); resolve(String(answer || '').trim()); });
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
async function resolveInstallOptions(root, opts = {}) {
|
|
246
|
-
const explicitLang = arg('--language', null);
|
|
247
|
-
const explicitSkills = arg('--skills', null);
|
|
248
|
-
let lang = explicitLang ? detectLanguageValue(root, explicitLang) : detectLanguageValue(root, 'auto');
|
|
249
|
-
let skills = explicitSkills ? parseSkillsValue(explicitSkills) : [];
|
|
250
|
-
const shouldAsk = !has('--yes') && !opts.nonInteractive && process.stdin.isTTY && process.stdout.isTTY && !opts.migration;
|
|
251
|
-
if (shouldAsk && !explicitLang) {
|
|
252
|
-
log('\n설치 언어를 선택하세요.');
|
|
253
|
-
log('1) 자동 감지');
|
|
254
|
-
log('2) 한국어');
|
|
255
|
-
log('3) English');
|
|
256
|
-
const a = await ask('선택 [1]: ');
|
|
257
|
-
lang = a === '2' ? 'ko' : a === '3' ? 'en' : detectLanguageValue(root, 'auto');
|
|
258
|
-
}
|
|
259
|
-
if (shouldAsk && !explicitSkills) {
|
|
260
|
-
log('\n설치할 스킬 라이브러리를 선택하세요.');
|
|
261
|
-
log('0) 기본 하네스만 설치');
|
|
262
|
-
log('1) 추천 스킬 설치: office, commerce-api, ai-verified-skill-publisher');
|
|
263
|
-
log('2) 전체 스킬 설치');
|
|
264
|
-
log('3) 직접 입력');
|
|
265
|
-
skillList();
|
|
266
|
-
const a = await ask('선택 [1]: ');
|
|
267
|
-
if (!a || a === '1') skills = parseSkillsValue('recommended');
|
|
268
|
-
else if (a === '2') skills = parseSkillsValue('all');
|
|
269
|
-
else if (a === '3') skills = parseSkillsValue(await ask('스킬 ID를 쉼표로 입력: '));
|
|
270
|
-
else if (a === '0') skills = [];
|
|
271
|
-
}
|
|
272
|
-
return { lang, skills };
|
|
273
|
-
}
|
|
194
|
+
|
|
274
195
|
function copyRecursiveSafe(src, dst) {
|
|
275
196
|
if (!exists(src)) return;
|
|
276
197
|
if (src.includes(path.sep + '.harness' + path.sep + 'archive')) return;
|
|
@@ -286,10 +207,11 @@ function copyRecursiveSafe(src, dst) {
|
|
|
286
207
|
fs.copyFileSync(src, dst);
|
|
287
208
|
}
|
|
288
209
|
}
|
|
210
|
+
|
|
289
211
|
function migrationCandidates(root, files) {
|
|
290
212
|
const fixed = [
|
|
291
|
-
'AGENTS.md','CLAUDE.md','.cursor/rules/leerness.mdc','.cursor/rules/project-rules.mdc',
|
|
292
|
-
'.github/copilot-instructions.md','README.md','.env.example','.gitignore',
|
|
213
|
+
'AGENTS.md','CLAUDE.md','.cursor/rules/leerness.mdc','.cursor/rules/leerness-plus.mdc','.cursor/rules/project-rules.mdc',
|
|
214
|
+
'.github/copilot-instructions.md','README.md','.env.example','.gitignore','.gitattributes',
|
|
293
215
|
'docs/guideline.md','docs/history.md','guideline.md','history.md',
|
|
294
216
|
'AI_HARNESS.md','HARNESS.md','PROJECT_CONTEXT.md','CONTEXT.md','ARCHITECTURE.md','DECISIONS.md','CURRENT_STATE.md','TASK_LOG.md',
|
|
295
217
|
'.harness','.ai','harness'
|
|
@@ -297,6 +219,7 @@ function migrationCandidates(root, files) {
|
|
|
297
219
|
const all = Array.from(new Set([...fixed, ...Object.keys(files)]));
|
|
298
220
|
return all.filter(f => exists(path.join(root, f)));
|
|
299
221
|
}
|
|
222
|
+
|
|
300
223
|
function createBackup(root, reason, files, dry = false) {
|
|
301
224
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
302
225
|
const ar = path.join(root, '.harness/archive', `leerness-${VERSION}-${stamp}`);
|
|
@@ -305,50 +228,88 @@ function createBackup(root, reason, files, dry = false) {
|
|
|
305
228
|
mkdirp(ar);
|
|
306
229
|
const fileRoot = path.join(ar, 'files');
|
|
307
230
|
for (const f of candidates) copyRecursiveSafe(path.join(root, f), path.join(fileRoot, f === '.harness' ? '.harness-before-migration' : f));
|
|
308
|
-
|
|
309
|
-
version: VERSION,
|
|
310
|
-
reason,
|
|
311
|
-
createdAt: now(),
|
|
231
|
+
writeUtf8(path.join(ar, 'migration-manifest.json'), JSON.stringify({
|
|
232
|
+
version: VERSION, reason, createdAt: now(),
|
|
312
233
|
policy: 'backup-before-write; preserve-by-default; merge-managed-files; merge-env-and-gitignore',
|
|
313
234
|
candidates
|
|
314
235
|
}, null, 2) + '\n');
|
|
315
236
|
return { archiveDir: ar, candidates };
|
|
316
237
|
}
|
|
238
|
+
|
|
317
239
|
function managedMerge(file, next, previous, archiveDir) {
|
|
318
240
|
if (!previous || previous.trim() === next.trim()) return next;
|
|
319
241
|
const tag = '<!-- leerness:migration-preserved -->';
|
|
320
242
|
if (previous.includes(tag)) return next;
|
|
321
|
-
return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\nPrevious content was backed up before migration. Archive reference:\n\n\`${archiveDir ? path.relative(process.cwd(), archiveDir).replace(/\\/g, '/') : '.harness/archive'}\`\n\
|
|
243
|
+
return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\nPrevious content was backed up before migration. Archive reference:\n\n\`${archiveDir ? path.relative(process.cwd(), archiveDir).replace(/\\/g, '/') : '.harness/archive'}\`\n\n<details>\n<summary>Previous ${file}</summary>\n\n\`\`\`md\n${previous.replace(/```/g, '\\`\\`\\`')}\n\`\`\`\n\n</details>\n`;
|
|
322
244
|
}
|
|
245
|
+
|
|
323
246
|
function writeIfSafe(root, file, content, opts = {}) {
|
|
324
247
|
const p = path.join(root, file);
|
|
325
248
|
const already = exists(p);
|
|
326
249
|
if (already && !opts.force && !opts.mergeManaged) return { action: 'preserved', file };
|
|
327
250
|
if (already && opts.mergeManaged && !opts.force) {
|
|
328
251
|
const prev = read(p);
|
|
329
|
-
|
|
252
|
+
writeUtf8(p, managedMerge(file, content, prev, opts.archiveDir));
|
|
330
253
|
return { action: 'merged', file };
|
|
331
254
|
}
|
|
332
|
-
|
|
255
|
+
writeUtf8(p, content);
|
|
333
256
|
return { action: already ? 'updated' : 'created', file };
|
|
334
257
|
}
|
|
258
|
+
|
|
335
259
|
function mergeLinesFile(p, lines) {
|
|
336
260
|
const current = exists(p) ? read(p) : '';
|
|
337
261
|
let next = current;
|
|
338
262
|
for (const line of lines) if (!next.includes(line)) next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n';
|
|
339
|
-
|
|
263
|
+
writeUtf8(p, next);
|
|
340
264
|
}
|
|
265
|
+
|
|
341
266
|
function writeMigrationReport(root, backup, actions) {
|
|
342
267
|
const p = path.join(root, '.harness/migration-report.md');
|
|
343
268
|
const rows = actions.map(a => `| ${a.file} | ${a.action} |`).join('\n');
|
|
344
|
-
|
|
269
|
+
writeUtf8(p, `# Leerness Migration Report\n\nVersion: ${VERSION}\nDate: ${now()}\nBackup: ${rel(root, backup.archiveDir)}\n\n## Policy\n\n- Existing harness, skill, and instruction files are backed up before migration.\n- Project memory files are preserved by default.\n- Managed instruction files are merged with previous content instead of being blindly overwritten.\n- .env.example/.gitignore are line-merged only.\n\n## Backed Up Candidates\n\n${backup.candidates.map(x => '- ' + x).join('\n')}\n\n## File Actions\n\n| File | Action |\n|---|---|\n${rows}\n`);
|
|
345
270
|
}
|
|
271
|
+
|
|
346
272
|
function syncReadme(root) {
|
|
347
273
|
const p = path.join(root, 'README.md');
|
|
348
274
|
const existing = exists(p) ? read(p) : '';
|
|
349
|
-
|
|
275
|
+
writeUtf8(p, mergeReadmeSection(existing, managedReadmeBlock(detectProjectName(root))));
|
|
350
276
|
ok('README.md Leerness section synced');
|
|
351
277
|
}
|
|
278
|
+
|
|
279
|
+
function parseSkillsValue(v) {
|
|
280
|
+
if (!v || v === true) return [];
|
|
281
|
+
if (v === 'all') return Object.keys(skillCatalog);
|
|
282
|
+
if (v === 'recommended') return ['office','commerce-api','ai-verified-skill-publisher','feature-implementation'];
|
|
283
|
+
return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => skillCatalog[s]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function resolveInstallOptions(root, opts = {}) {
|
|
287
|
+
const explicitLang = arg('--language', null);
|
|
288
|
+
const explicitSkills = arg('--skills', null);
|
|
289
|
+
let lang = explicitLang ? detectLanguageValue(root, explicitLang) : detectLanguageValue(root, 'auto');
|
|
290
|
+
let skills = explicitSkills ? parseSkillsValue(explicitSkills) : [];
|
|
291
|
+
const shouldAsk = !has('--yes') && !opts.nonInteractive && process.stdin.isTTY && process.stdout.isTTY && !opts.migration;
|
|
292
|
+
if (shouldAsk && !explicitLang) {
|
|
293
|
+
log('\n설치 언어를 선택하세요.');
|
|
294
|
+
log('1) 자동 감지'); log('2) 한국어'); log('3) English');
|
|
295
|
+
const a = await ask('선택 [1]: ');
|
|
296
|
+
lang = a === '2' ? 'ko' : a === '3' ? 'en' : detectLanguageValue(root, 'auto');
|
|
297
|
+
}
|
|
298
|
+
if (shouldAsk && !explicitSkills) {
|
|
299
|
+
log('\n설치할 스킬 라이브러리를 선택하세요.');
|
|
300
|
+
log('0) 기본 하네스만 설치');
|
|
301
|
+
log('1) 추천: office, commerce-api, ai-verified-skill-publisher, feature-implementation');
|
|
302
|
+
log('2) 전체 스킬 설치'); log('3) 직접 입력');
|
|
303
|
+
skillList();
|
|
304
|
+
const a = await ask('선택 [1]: ');
|
|
305
|
+
if (!a || a === '1') skills = parseSkillsValue('recommended');
|
|
306
|
+
else if (a === '2') skills = parseSkillsValue('all');
|
|
307
|
+
else if (a === '3') skills = parseSkillsValue(await ask('스킬 ID를 쉼표로 입력: '));
|
|
308
|
+
else if (a === '0') skills = [];
|
|
309
|
+
}
|
|
310
|
+
return { lang, skills };
|
|
311
|
+
}
|
|
312
|
+
|
|
352
313
|
async function install(root, opts = {}) {
|
|
353
314
|
root = absRoot(root); mkdirp(root);
|
|
354
315
|
const resolved = await resolveInstallOptions(root, opts);
|
|
@@ -372,14 +333,16 @@ async function install(root, opts = {}) {
|
|
|
372
333
|
'.harness/HARNESS_VERSION','.harness/manifest.json','.harness/LANGUAGE','.harness/skills-lock.json',
|
|
373
334
|
'.harness/context-routing.md','.harness/writeback-policy.md','.harness/task-type-map.md',
|
|
374
335
|
'.harness/leerness-maintenance.md','.harness/protected-files.md','.harness/AX_MIGRATION_GUIDE.md',
|
|
375
|
-
'.harness/AX_NEW_PROJECT_GUIDE.md','.harness/AX_SKILL_LIBRARY_GUIDE.md'
|
|
336
|
+
'.harness/AX_NEW_PROJECT_GUIDE.md','.harness/AX_SKILL_LIBRARY_GUIDE.md','.harness/skill-index.md',
|
|
337
|
+
'.claude/commands/handoff.md','.claude/commands/session-close.md','.claude/commands/audit.md','.claude/commands/lazy-detect.md','.claude/commands/update.md',
|
|
338
|
+
'.claude/skills/leerness.md'
|
|
376
339
|
]);
|
|
377
340
|
const actions = [];
|
|
378
341
|
for (const [f, c] of Object.entries(files)) {
|
|
379
342
|
const existsNow = exists(path.join(root, f));
|
|
380
343
|
const mergeManaged = managedOverwrite.has(f);
|
|
381
344
|
if (opts.dry) {
|
|
382
|
-
const action = existsNow ? (mergeManaged || opts.force ? 'merge/update
|
|
345
|
+
const action = existsNow ? (mergeManaged || opts.force ? 'merge/update' : 'preserve') : 'create';
|
|
383
346
|
log(`[dry-run] ${action}: ${f}`);
|
|
384
347
|
actions.push({ file:f, action });
|
|
385
348
|
continue;
|
|
@@ -389,84 +352,737 @@ async function install(root, opts = {}) {
|
|
|
389
352
|
ok(`${r.action}: ${r.file}`);
|
|
390
353
|
}
|
|
391
354
|
if (!opts.dry) {
|
|
392
|
-
mergeLinesFile(path.join(root, '.gitignore'), [
|
|
393
|
-
|
|
355
|
+
mergeLinesFile(path.join(root, '.gitignore'), [
|
|
356
|
+
'.harness/skill-publish.local.json','.harness/**/*.local.json','.env.local',
|
|
357
|
+
'.harness/archive/','.harness/migration-report.md','.harness/cache/'
|
|
358
|
+
]);
|
|
359
|
+
mergeLinesFile(path.join(root, '.env.example'), [
|
|
360
|
+
'# Leerness uses environment variable names only. Do not store real secrets here.',
|
|
361
|
+
'LEERNESS_NPM_TOKEN=','LEERNESS_GITHUB_TOKEN='
|
|
362
|
+
]);
|
|
363
|
+
mergeLinesFile(path.join(root, '.gitattributes'), [
|
|
364
|
+
'* text=auto eol=lf','*.bat text eol=crlf','*.ps1 text eol=crlf'
|
|
365
|
+
]);
|
|
394
366
|
syncReadme(root);
|
|
395
367
|
installSkills(root, skills);
|
|
396
368
|
writeMigrationReport(root, backup, actions);
|
|
369
|
+
if (!has('--no-auto-update')) {
|
|
370
|
+
try { autoUpdateInstall(root); } catch (e) { warn('auto-update hook install skipped: ' + (e && e.message)); }
|
|
371
|
+
}
|
|
397
372
|
}
|
|
398
373
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
374
|
+
|
|
375
|
+
function installSkills(root, skills) { for (const name of skills) addSkill(root, name, true); }
|
|
402
376
|
function addSkill(root, name, silent = false) {
|
|
403
|
-
const meta = skillCatalog[name];
|
|
377
|
+
const meta = skillCatalog[name];
|
|
378
|
+
if (!meta) { fail(`Unknown skill: ${name}`); return; }
|
|
404
379
|
const dir = path.join(root, '.harness/skills', name); mkdirp(dir);
|
|
405
|
-
|
|
406
|
-
|
|
380
|
+
writeUtf8(path.join(dir, 'skill.json'), JSON.stringify({ name, ...meta, verification: { status: meta.verification, method: 'leerness-curated' } }, null, 2) + '\n');
|
|
381
|
+
writeUtf8(path.join(dir, 'README.md'), `# ${meta.displayNameKo}\n\n## Capabilities\n${meta.capabilities.map(x => '- ' + x).join('\n')}\n\n## Sensitive Data Policy\n실제 토큰이나 비밀번호를 기록하지 않고 환경변수 이름만 기록합니다.\n`);
|
|
407
382
|
if (!silent) ok(`skill installed: ${name}`);
|
|
408
383
|
}
|
|
384
|
+
|
|
409
385
|
function skillList() {
|
|
410
386
|
log('| ID | 한글명 | 가능한 작업 | 최종 업데이트 | 검증 |');
|
|
411
387
|
log('|---|---|---|---|---|');
|
|
412
388
|
for (const [k, v] of Object.entries(skillCatalog)) log(`| ${k} | ${v.displayNameKo} | ${v.capabilities.join('<br>')} | ${v.lastUpdated} | ${v.verification} |`);
|
|
413
389
|
}
|
|
414
390
|
function skillInfo(name) {
|
|
415
|
-
const v = skillCatalog[name];
|
|
416
|
-
|
|
391
|
+
const v = skillCatalog[name];
|
|
392
|
+
if (!v) return fail(`Unknown skill: ${name}`);
|
|
393
|
+
log(`${name}`); log(`한글명: ${v.displayNameKo}`); log(`버전: ${v.version}`);
|
|
394
|
+
log(`최종 업데이트: ${v.lastUpdated}`); log(`검증: ${v.verification}`);
|
|
395
|
+
log('가능한 작업:'); v.capabilities.forEach(x => log('- ' + x));
|
|
417
396
|
}
|
|
418
|
-
|
|
419
|
-
|
|
397
|
+
|
|
398
|
+
const planPath = root => path.join(root, '.harness/plan.md');
|
|
399
|
+
const progressPath = root => path.join(root, '.harness/progress-tracker.md');
|
|
400
|
+
const taskLogPath = root => path.join(root, '.harness/task-log.md');
|
|
401
|
+
const evidencePath = root => path.join(root, '.harness/review-evidence.md');
|
|
402
|
+
const handoffPath = root => path.join(root, '.harness/session-handoff.md');
|
|
403
|
+
const currentStatePath = root => path.join(root, '.harness/current-state.md');
|
|
404
|
+
const decisionsPath = root => path.join(root, '.harness/decisions.md');
|
|
405
|
+
|
|
420
406
|
function nextId(root, prefix) {
|
|
421
|
-
const
|
|
422
|
-
const re = new RegExp(prefix + '-(\\d{4})', 'g'); let max = 0, m;
|
|
423
|
-
while ((m = re.exec(
|
|
407
|
+
const sources = [planPath(root), progressPath(root)].map(p => exists(p) ? read(p) : '').join('\n');
|
|
408
|
+
const re = new RegExp('\\b' + prefix + '-(\\d{4})\\b', 'g'); let max = 0, m;
|
|
409
|
+
while ((m = re.exec(sources))) max = Math.max(max, Number(m[1]));
|
|
424
410
|
return `${prefix}-${String(max + 1).padStart(4, '0')}`;
|
|
425
411
|
}
|
|
412
|
+
|
|
413
|
+
function readProgressRows(root) {
|
|
414
|
+
const text = exists(progressPath(root)) ? read(progressPath(root)) : '';
|
|
415
|
+
const rows = [];
|
|
416
|
+
for (const line of text.split('\n')) {
|
|
417
|
+
if (!/^\| (?:T|M|D)-\d{4} \|/.test(line)) continue;
|
|
418
|
+
const cells = line.split('|').slice(1, -1).map(s => s.trim());
|
|
419
|
+
if (cells.length < 6) continue;
|
|
420
|
+
const [id, status, request, evidence, nextAction, updated] = cells;
|
|
421
|
+
rows.push({ id, status, request, evidence, nextAction, updated });
|
|
422
|
+
}
|
|
423
|
+
return rows;
|
|
424
|
+
}
|
|
425
|
+
function progressHeader(root) {
|
|
426
|
+
const text = exists(progressPath(root)) ? read(progressPath(root)) : '';
|
|
427
|
+
const idx = text.indexOf('|---|');
|
|
428
|
+
if (idx < 0) return text.trim();
|
|
429
|
+
return text.slice(0, text.indexOf('\n', idx)).trimEnd();
|
|
430
|
+
}
|
|
431
|
+
function writeProgressRows(root, header, rows) {
|
|
432
|
+
const composed = header + '\n' +
|
|
433
|
+
rows.map(r => `| ${r.id} | ${r.status} | ${r.request} | ${r.evidence} | ${r.nextAction} | ${r.updated} |`).join('\n') +
|
|
434
|
+
(rows.length ? '\n' : '');
|
|
435
|
+
writeUtf8(progressPath(root), composed);
|
|
436
|
+
}
|
|
437
|
+
function upsertProgress(root, row) {
|
|
438
|
+
const header = progressHeader(root);
|
|
439
|
+
const rows = readProgressRows(root);
|
|
440
|
+
const i = rows.findIndex(r => r.id === row.id);
|
|
441
|
+
if (i >= 0) rows[i] = { ...rows[i], ...row, updated: today() };
|
|
442
|
+
else rows.push({ ...row, updated: today() });
|
|
443
|
+
writeProgressRows(root, header, rows);
|
|
444
|
+
}
|
|
445
|
+
|
|
426
446
|
function planShow(root) { const p = planPath(root); log(exists(p) ? read(p) : 'plan.md not found'); }
|
|
427
|
-
function planInit(root) { const goal = arg('--goal', ''); if (!exists(planPath(root))) install(root); append(planPath(root), `\n## User Goal\n- ${goal || '사용자 목적을 작성하세요.'}\n`); ok('plan
|
|
428
|
-
function planAdd(root, text) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
function
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
447
|
+
function planInit(root) { const goal = arg('--goal', ''); if (!exists(planPath(root))) return install(root); append(planPath(root), `\n## User Goal\n- ${goal || '사용자 목적을 작성하세요.'}\n`); ok('plan goal appended'); }
|
|
448
|
+
function planAdd(root, text) {
|
|
449
|
+
const id = nextId(root, 'M');
|
|
450
|
+
const status = arg('--status','planned'), progress = arg('--progress','0');
|
|
451
|
+
append(planPath(root), `\n### ${id}. ${text}\nStatus: ${status}\nProgress: ${progress}%\n\nTasks:\n- [ ] ${text}\n`);
|
|
452
|
+
const tid = nextId(root, 'T');
|
|
453
|
+
upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction: arg('--next', '다음 액션 작성') });
|
|
454
|
+
ok(`plan added: ${id} → progress: ${tid}`);
|
|
455
|
+
}
|
|
456
|
+
function planDrop(root, text) {
|
|
457
|
+
const id = nextId(root, 'D');
|
|
458
|
+
const reason = arg('--reason', '사용자 요청으로 제외');
|
|
459
|
+
const planFile = planPath(root); let p = exists(planFile) ? read(planFile) : '';
|
|
460
|
+
const droppedHeader = '## Out of Scope / Dropped';
|
|
461
|
+
if (p.includes(droppedHeader)) {
|
|
462
|
+
p = p.replace(droppedHeader + '\n| ID | Item | Reason | Date |\n|---|---|---|---|\n',
|
|
463
|
+
droppedHeader + '\n| ID | Item | Reason | Date |\n|---|---|---|---|\n' + `| ${id} | ${text} | ${reason} | ${today()} |\n`);
|
|
464
|
+
writeUtf8(planFile, p);
|
|
465
|
+
} else {
|
|
466
|
+
append(planFile, `\n${droppedHeader}\n| ID | Item | Reason | Date |\n|---|---|---|---|\n| ${id} | ${text} | ${reason} | ${today()} |\n`);
|
|
467
|
+
}
|
|
468
|
+
const tid = nextId(root, 'T');
|
|
469
|
+
upsertProgress(root, { id: tid, status: 'dropped', request: text, evidence: `drop:${reason}`, nextAction: '없음' });
|
|
470
|
+
ok(`plan dropped: ${id} → progress: ${tid}`);
|
|
471
|
+
}
|
|
472
|
+
function planProgress(root) {
|
|
473
|
+
const rows = readProgressRows(root);
|
|
474
|
+
const counts = {}; for (const s of STATUSES) counts[s] = 0;
|
|
475
|
+
for (const r of rows) if (counts[r.status] != null) counts[r.status]++;
|
|
476
|
+
log(JSON.stringify(counts, null, 2));
|
|
477
|
+
}
|
|
478
|
+
function planSync(root) { append(taskLogPath(root), `\n## ${today()}\n- Synced plan.md and progress-tracker.md.\n`); ok('plan/progress sync noted'); }
|
|
479
|
+
|
|
480
|
+
function taskList(root) {
|
|
481
|
+
const rows = readProgressRows(root);
|
|
482
|
+
if (!rows.length) return log('(no tasks)');
|
|
483
|
+
log('| ID | Status | Request | Evidence | Next Action | Updated |');
|
|
484
|
+
log('|---|---|---|---|---|---|');
|
|
485
|
+
for (const r of rows) log(`| ${r.id} | ${r.status} | ${r.request} | ${r.evidence} | ${r.nextAction} | ${r.updated} |`);
|
|
486
|
+
}
|
|
487
|
+
function taskAdd(root, text) {
|
|
488
|
+
const id = nextId(root, 'T');
|
|
489
|
+
upsertProgress(root, { id, status: arg('--status','requested'), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
|
|
490
|
+
ok(`task added: ${id}`);
|
|
491
|
+
}
|
|
492
|
+
function taskUpdate(root, id) {
|
|
493
|
+
if (!id) return fail('id required (e.g., task update T-0001 --status in-progress)');
|
|
494
|
+
const rows = readProgressRows(root);
|
|
495
|
+
if (!rows.find(r => r.id === id)) { fail(`task ${id} not found in progress-tracker.md`); return; }
|
|
496
|
+
const patch = { id };
|
|
497
|
+
if (arg('--status') !== null) patch.status = arg('--status');
|
|
498
|
+
if (arg('--evidence') !== null) patch.evidence = arg('--evidence');
|
|
499
|
+
if (arg('--next') !== null) patch.nextAction = arg('--next');
|
|
500
|
+
if (arg('--note')) patch.request = arg('--note');
|
|
501
|
+
upsertProgress(root, patch);
|
|
502
|
+
ok(`task updated: ${id}`);
|
|
503
|
+
}
|
|
504
|
+
function taskDrop(root, id) {
|
|
505
|
+
if (!id) return fail('id required');
|
|
506
|
+
upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
|
|
507
|
+
ok(`task dropped: ${id}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function route(name) {
|
|
511
|
+
const r = routes[name];
|
|
512
|
+
if (!r) { fail('Unknown route'); log('Available: ' + Object.keys(routes).join(', ')); return; }
|
|
513
|
+
log(`# Route: ${name}\n`);
|
|
514
|
+
log('Read before work:'); r.read.forEach(x => log('- ' + x));
|
|
515
|
+
log('\nUpdate after work:'); r.update.forEach(x => log('- ' + x));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function status(root) {
|
|
519
|
+
root = absRoot(root);
|
|
520
|
+
const verF = path.join(root,'.harness/HARNESS_VERSION');
|
|
521
|
+
const ver = exists(verF) ? read(verF).trim() : 'not installed';
|
|
522
|
+
const lang = exists(path.join(root,'.harness/LANGUAGE')) ? read(path.join(root,'.harness/LANGUAGE')).trim() : 'ko';
|
|
523
|
+
const files = Object.keys(coreFiles(root, lang));
|
|
524
|
+
const missing = files.filter(f => !exists(path.join(root,f)));
|
|
525
|
+
log(`Leerness: ${ver}`);
|
|
526
|
+
log(`Files: ${files.length - missing.length}/${files.length}`);
|
|
527
|
+
if (missing.length) missing.forEach(x => warn('missing: ' + x));
|
|
528
|
+
else ok('required files present');
|
|
529
|
+
}
|
|
530
|
+
function verify(root) {
|
|
531
|
+
root = absRoot(root);
|
|
532
|
+
let bad = 0;
|
|
533
|
+
const required = ['.harness/plan.md','.harness/progress-tracker.md','.harness/guideline.md','.harness/protected-files.md','.harness/design-system.md','.harness/anti-lazy-work-policy.md','.harness/session-handoff.md','.harness/current-state.md','AGENTS.md'];
|
|
534
|
+
for (const f of required) { if (!exists(path.join(root,f))) { bad++; fail(`missing: ${f}`); } }
|
|
535
|
+
const g = exists(path.join(root,'.harness/guideline.md')) ? read(path.join(root,'.harness/guideline.md')) : '';
|
|
536
|
+
if (!g.includes('plan.md') || !g.includes('progress-tracker.md')) { bad++; fail('guideline.md must reference plan.md and progress-tracker.md'); }
|
|
537
|
+
const a = exists(path.join(root,'AGENTS.md')) ? read(path.join(root,'AGENTS.md')) : '';
|
|
538
|
+
if (!a.includes('protected-files.md')) { bad++; fail('AGENTS.md must reference protected-files.md'); }
|
|
539
|
+
if (!a.includes('anti-lazy-work-policy.md')) { bad++; fail('AGENTS.md must reference anti-lazy-work-policy.md'); }
|
|
540
|
+
if (bad) process.exitCode = 1; else ok('verify passed');
|
|
541
|
+
}
|
|
542
|
+
function debug(root) {
|
|
543
|
+
root = absRoot(root); let warnings = 0, failures = 0;
|
|
544
|
+
const checks = ['.harness/context-routing.md','.harness/writeback-policy.md','.harness/plan-progress-boundary.md','.harness/consistency-policy.md','.harness/reuse-map.md','.harness/leerness-maintenance.md','.harness/anti-lazy-work-policy.md','.harness/encoding-policy.md','.harness/secret-policy.md'];
|
|
545
|
+
for (const f of checks) { if (exists(path.join(root,f))) ok(f); else { warnings++; warn('missing: ' + f); } }
|
|
546
|
+
const pg = exists(planPath(root)) && exists(progressPath(root));
|
|
547
|
+
if (pg) ok('plan/progress files exist'); else { failures++; fail('plan/progress missing'); }
|
|
548
|
+
log(`Debug summary: warnings=${warnings} failures=${failures}`);
|
|
549
|
+
if (failures) process.exitCode = 1;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function audit(root) {
|
|
553
|
+
root = absRoot(root);
|
|
554
|
+
let warnings = 0, failures = 0;
|
|
555
|
+
const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
|
|
556
|
+
const dups = designCands.filter(f => exists(path.join(root,f)));
|
|
557
|
+
if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); }
|
|
558
|
+
else ok('no duplicate design guide candidates');
|
|
559
|
+
const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
|
|
560
|
+
if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); }
|
|
561
|
+
else ok('design-system tokens populated');
|
|
562
|
+
const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
|
|
563
|
+
const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
|
|
564
|
+
if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); }
|
|
565
|
+
else ok(`reuse-map.md has ${reuseLines} entries`);
|
|
566
|
+
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
567
|
+
const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
|
|
568
|
+
const rows = readProgressRows(root);
|
|
569
|
+
const linkedMs = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
|
|
570
|
+
const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
|
|
571
|
+
if (missingFromProgress.length) { warnings++; warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`); }
|
|
572
|
+
else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
|
|
573
|
+
const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
574
|
+
if (handoff.includes('Last generated: (자동)')) { warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)'); }
|
|
575
|
+
else if (handoff.includes('Last generated:')) ok('session-handoff.md auto-generated previously');
|
|
576
|
+
const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
|
|
577
|
+
const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
|
|
578
|
+
if (updMatch) {
|
|
579
|
+
const dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
|
|
580
|
+
if (dDays > 7) { warnings++; warn(`current-state.md stale (${Math.round(dDays)} days)`); }
|
|
581
|
+
else ok('current-state.md fresh');
|
|
582
|
+
}
|
|
583
|
+
log(`Audit summary: warnings=${warnings} failures=${failures}`);
|
|
584
|
+
if (failures) process.exitCode = 1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const SECRET_PATTERNS = [
|
|
588
|
+
{ name: 'AWS Access Key', re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
589
|
+
{ name: 'GitHub PAT', re: /\bghp_[A-Za-z0-9]{36}\b/g },
|
|
590
|
+
{ name: 'GitHub fine-grained PAT', re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g },
|
|
591
|
+
{ name: 'OpenAI API key', re: /\bsk-[A-Za-z0-9]{32,}\b/g },
|
|
592
|
+
{ name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9-]{20,}\b/g },
|
|
593
|
+
{ name: 'Google API key', re: /\bAIza[0-9A-Za-z_\-]{35}\b/g },
|
|
594
|
+
{ name: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
595
|
+
{ name: 'Generic private key', re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/g },
|
|
596
|
+
{ name: 'Hardcoded password assignment', re: /\b(?:password|passwd|pwd|secret|api_key|apikey)\s*[:=]\s*["'][^"'\s]{6,}["']/gi },
|
|
597
|
+
];
|
|
598
|
+
const SCAN_SKIP_DIRS = new Set(['.git','node_modules','.harness/archive','.viewwork','dist','build','.next','.turbo','.cache','coverage','_pkg-source']);
|
|
599
|
+
const SCAN_TEXT_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.json','.md','.txt','.env','.bash','.sh','.yml','.yaml','.toml','.ini','.cfg','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php','.sql','.html','.css','.scss','.less','.xml','.bat','.ps1','']);
|
|
600
|
+
function* walk(root, base = root, depth = 0) {
|
|
601
|
+
if (depth > 12) return;
|
|
602
|
+
for (const e of fs.readdirSync(base, { withFileTypes: true })) {
|
|
603
|
+
const p = path.join(base, e.name);
|
|
604
|
+
const r = path.relative(root, p).replace(/\\/g, '/');
|
|
605
|
+
if (Array.from(SCAN_SKIP_DIRS).some(d => r === d || r.startsWith(d + '/'))) continue;
|
|
606
|
+
if (e.isDirectory()) yield* walk(root, p, depth + 1);
|
|
607
|
+
else yield p;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function scanSecrets(root) {
|
|
611
|
+
root = absRoot(root);
|
|
612
|
+
const findings = [];
|
|
613
|
+
for (const file of walk(root)) {
|
|
614
|
+
const ext = path.extname(file).toLowerCase();
|
|
615
|
+
if (!SCAN_TEXT_EXT.has(ext)) continue;
|
|
616
|
+
let text;
|
|
617
|
+
try { text = read(file); } catch { continue; }
|
|
618
|
+
if (text.length > 1024 * 1024) continue;
|
|
619
|
+
const fileRel = rel(root, file);
|
|
620
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
621
|
+
re.lastIndex = 0;
|
|
622
|
+
let m;
|
|
623
|
+
while ((m = re.exec(text))) {
|
|
624
|
+
if (fileRel.includes('harness.js') || fileRel.includes('secret-policy.md')) break;
|
|
625
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
626
|
+
findings.push({ file: fileRel, line, name, snippet: m[0].slice(0, 32) });
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (findings.length) {
|
|
632
|
+
fail(`secret patterns found: ${findings.length}`);
|
|
633
|
+
findings.forEach(f => log(` ${f.file}:${f.line} ${f.name} ${f.snippet}…`));
|
|
634
|
+
process.exitCode = 1;
|
|
635
|
+
} else {
|
|
636
|
+
ok('no obvious secret patterns');
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function encodingCheck(root) {
|
|
641
|
+
root = absRoot(root);
|
|
642
|
+
let warnings = 0; const findings = [];
|
|
643
|
+
for (const file of walk(root)) {
|
|
644
|
+
const ext = path.extname(file).toLowerCase();
|
|
645
|
+
if (!SCAN_TEXT_EXT.has(ext)) continue;
|
|
646
|
+
let buf;
|
|
647
|
+
try { buf = readBuf(file); } catch { continue; }
|
|
648
|
+
if (buf.length === 0) continue;
|
|
649
|
+
if (buf.length > 5 * 1024 * 1024) continue;
|
|
650
|
+
const fileRel = rel(root, file);
|
|
651
|
+
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) { warnings++; findings.push({ file: fileRel, issue: 'UTF-8 BOM' }); }
|
|
652
|
+
else if ((buf[0] === 0xFF && buf[1] === 0xFE) || (buf[0] === 0xFE && buf[1] === 0xFF)) { warnings++; findings.push({ file: fileRel, issue: 'UTF-16 BOM' }); }
|
|
653
|
+
let nul = false; for (let i = 0; i < Math.min(buf.length, 4096); i++) if (buf[i] === 0) { nul = true; break; }
|
|
654
|
+
if (nul) { warnings++; findings.push({ file: fileRel, issue: 'NUL byte (binary in text path)' }); }
|
|
655
|
+
if (ext === '.bat') {
|
|
656
|
+
const text = buf.toString('utf8').replace(/^/, '');
|
|
657
|
+
if (!/^@?chcp\s+65001/i.test(text.split(/\r?\n/, 1)[0] || '')) { warnings++; findings.push({ file: fileRel, issue: '.bat missing chcp 65001' }); }
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
const text = buf.toString('utf8');
|
|
661
|
+
if (/[가-힣]/.test(text)) {
|
|
662
|
+
const reBuf = Buffer.from(text, 'utf8');
|
|
663
|
+
if (!reBuf.equals(buf) && !(buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF)) {
|
|
664
|
+
warnings++; findings.push({ file: fileRel, issue: 'Korean text but non-clean UTF-8 roundtrip' });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} catch {}
|
|
668
|
+
}
|
|
669
|
+
if (findings.length) {
|
|
670
|
+
warn(`encoding issues: ${findings.length}`);
|
|
671
|
+
findings.forEach(f => log(` ${f.file} ${f.issue}`));
|
|
672
|
+
process.exitCode = warnings > 0 ? 1 : 0;
|
|
673
|
+
} else {
|
|
674
|
+
ok('encoding check passed');
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function lazyDetect(root) {
|
|
679
|
+
root = absRoot(root);
|
|
680
|
+
let issues = 0;
|
|
681
|
+
const rows = readProgressRows(root);
|
|
682
|
+
for (const r of rows) if (r.status === 'done' && (!r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:/.test(r.evidence))) {
|
|
683
|
+
issues++; warn(`done row without verifiable evidence: ${r.id} (${r.request})`);
|
|
684
|
+
}
|
|
685
|
+
if (rows.length === 0) { issues++; warn('progress-tracker is empty (no tasks tracked)'); }
|
|
686
|
+
const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
687
|
+
if (!handoff.includes('Last generated:') || handoff.includes('Last generated: (자동)')) {
|
|
688
|
+
issues++; warn('session-handoff.md never auto-generated');
|
|
689
|
+
}
|
|
690
|
+
if (/^## Completed\s*\n-\s*\n/m.test(handoff) && /^## Next Exact Step\s*\n-\s*\n?/m.test(handoff)) {
|
|
691
|
+
issues++; warn('session-handoff.md has empty Completed and Next Exact Step');
|
|
692
|
+
}
|
|
693
|
+
const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
|
|
694
|
+
const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress)\b/i.test(ev);
|
|
695
|
+
if (!hasTestRun) { issues++; warn('review-evidence.md has no recorded test/typecheck/lint run'); }
|
|
696
|
+
let todoCount = 0;
|
|
697
|
+
for (const file of walk(root)) {
|
|
698
|
+
const ext = path.extname(file).toLowerCase();
|
|
699
|
+
if (!SCAN_TEXT_EXT.has(ext) || file.includes('.harness') || file.includes('harness.js')) continue;
|
|
700
|
+
let text; try { text = read(file); } catch { continue; }
|
|
701
|
+
todoCount += (text.match(/\bTODO\b|\bFIXME\b|\bXXX\b/g) || []).length;
|
|
702
|
+
}
|
|
703
|
+
if (todoCount > 0) {
|
|
704
|
+
const hasTodoTask = rows.some(r => /TODO|FIXME|XXX/.test(r.request) || /TODO|FIXME|XXX/i.test(r.evidence));
|
|
705
|
+
if (!hasTodoTask) { issues++; warn(`code has ${todoCount} TODO/FIXME/XXX but no progress-tracker entry tracks them`); }
|
|
706
|
+
}
|
|
707
|
+
const blockers = rows.filter(r => r.status === 'blocked');
|
|
708
|
+
for (const b of blockers) if (b.nextAction === '없음' || /다음 액션 작성/.test(b.nextAction)) { issues++; warn(`blocker without nextAction: ${b.id}`); }
|
|
709
|
+
if (issues === 0) ok('lazy detect passed (no obvious lazy work signals)');
|
|
710
|
+
else { fail(`lazy detect found ${issues} issues`); process.exitCode = 1; }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function preCheck(root) {
|
|
714
|
+
root = absRoot(root);
|
|
715
|
+
let issues = 0;
|
|
716
|
+
const required = ['.harness/plan.md','.harness/progress-tracker.md','.harness/protected-files.md','AGENTS.md'];
|
|
717
|
+
for (const f of required) if (!exists(path.join(root,f))) { issues++; fail(`missing: ${f}`); }
|
|
718
|
+
if (exists(handoffPath(root))) ok('session-handoff present');
|
|
719
|
+
if (exists(currentStatePath(root))) ok('current-state present');
|
|
720
|
+
if (exists(planPath(root))) ok('plan present');
|
|
721
|
+
const pf = exists(path.join(root,'.harness/protected-files.md')) ? read(path.join(root,'.harness/protected-files.md')) : '';
|
|
722
|
+
if (!pf.includes('AGENTS.md')) { issues++; fail('protected-files.md missing AGENTS.md'); }
|
|
723
|
+
if (issues === 0) ok('pre-action check passed');
|
|
724
|
+
else { process.exitCode = 1; }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function memorySearch(root, query) {
|
|
728
|
+
root = absRoot(root);
|
|
729
|
+
if (!query) { fail('query required (e.g., memory search "키워드")'); return; }
|
|
730
|
+
const files = ['.harness/decisions.md','.harness/task-log.md','.harness/session-handoff.md','.harness/progress-tracker.md','.harness/plan.md','.harness/review-evidence.md','.harness/architecture.md'];
|
|
731
|
+
const re = new RegExp(query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i');
|
|
732
|
+
let total = 0;
|
|
733
|
+
for (const f of files) {
|
|
734
|
+
const p = path.join(root, f); if (!exists(p)) continue;
|
|
735
|
+
const lines = read(p).split('\n');
|
|
736
|
+
const hits = lines.map((line, i) => ({ line, i })).filter(x => re.test(x.line));
|
|
737
|
+
if (hits.length) {
|
|
738
|
+
log(`\n# ${f}`);
|
|
739
|
+
for (const h of hits.slice(0, parseInt(arg('--limit','5'),10))) log(` L${h.i+1}: ${h.line.trim()}`);
|
|
740
|
+
total += hits.length;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (total === 0) log('(no matches)');
|
|
744
|
+
else log(`\n${total} matches`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function handoff(root) {
|
|
748
|
+
root = absRoot(root);
|
|
749
|
+
const sections = [];
|
|
750
|
+
function block(label, p) {
|
|
751
|
+
if (!exists(p)) return;
|
|
752
|
+
sections.push(`\n=== ${label} (${rel(root,p)}) ===\n${read(p).trim()}`);
|
|
753
|
+
}
|
|
754
|
+
block('Session Handoff', handoffPath(root));
|
|
755
|
+
block('Current State', currentStatePath(root));
|
|
756
|
+
block('Plan', planPath(root));
|
|
757
|
+
block('Progress Tracker', progressPath(root));
|
|
758
|
+
block('Decisions (last 40 lines)', decisionsPath(root));
|
|
759
|
+
block('Task Log (last 60 lines)', taskLogPath(root));
|
|
760
|
+
const out = sections.map(s => s.length <= 4000 ? s : s.slice(0, 4000) + '\n…(truncated)').join('\n');
|
|
761
|
+
log('# Session Start Context');
|
|
762
|
+
log(`Date: ${today()}`);
|
|
763
|
+
log(`Project: ${detectProjectName(root)}`);
|
|
764
|
+
log(out);
|
|
765
|
+
if (exists(currentStatePath(root))) {
|
|
766
|
+
const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
767
|
+
writeUtf8(currentStatePath(root), cs);
|
|
768
|
+
}
|
|
769
|
+
ok('handoff loaded; current-state updated');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function sessionClose(root) {
|
|
773
|
+
root = absRoot(root);
|
|
774
|
+
const rows = readProgressRows(root);
|
|
775
|
+
const buckets = {};
|
|
776
|
+
for (const s of STATUSES) buckets[s] = [];
|
|
777
|
+
for (const r of rows) (buckets[r.status] || (buckets[r.status] = [])).push(r);
|
|
778
|
+
|
|
779
|
+
function rowsToList(arr) {
|
|
780
|
+
if (!arr || !arr.length) return '- 없음';
|
|
781
|
+
return arr.map(r => `- ${r.id} ${r.request} → next: ${r.nextAction}`).join('\n');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const evidenceSummary = exists(evidencePath(root)) ? (read(evidencePath(root)).split('\n').slice(-30).join('\n')) : '(no review-evidence.md)';
|
|
785
|
+
const block = [
|
|
786
|
+
`# Session Handoff`,
|
|
787
|
+
``,
|
|
788
|
+
`Last generated: ${now()}`,
|
|
789
|
+
``,
|
|
790
|
+
`## Completed`,
|
|
791
|
+
rowsToList(buckets['done']),
|
|
792
|
+
``,
|
|
793
|
+
`## In Progress`,
|
|
794
|
+
rowsToList(buckets['in-progress']),
|
|
795
|
+
``,
|
|
796
|
+
`## Incomplete / Waiting / On Hold / Blocked`,
|
|
797
|
+
rowsToList([...(buckets['incomplete']||[]), ...(buckets['waiting']||[]), ...(buckets['on-hold']||[]), ...(buckets['blocked']||[])]),
|
|
798
|
+
``,
|
|
799
|
+
`## Dropped`,
|
|
800
|
+
rowsToList(buckets['dropped']),
|
|
801
|
+
``,
|
|
802
|
+
`## Verification`,
|
|
803
|
+
'```',
|
|
804
|
+
evidenceSummary.trim() || '(empty)',
|
|
805
|
+
'```',
|
|
806
|
+
``,
|
|
807
|
+
`## Recommended Direction`,
|
|
808
|
+
`- ${(buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '다음 우선순위를 사용자와 정합니다.'}`,
|
|
809
|
+
``,
|
|
810
|
+
`## Next Exact Step`,
|
|
811
|
+
`- ${(buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || '없음'}`,
|
|
812
|
+
``
|
|
813
|
+
].join('\n');
|
|
814
|
+
const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
815
|
+
const fmEnd = cur.indexOf('\n---\n', 4);
|
|
816
|
+
const frontmatter = fmEnd > 0 ? cur.slice(0, fmEnd + 5) + MARK + '\n' : '';
|
|
817
|
+
writeUtf8(handoffPath(root), (frontmatter || '') + block);
|
|
818
|
+
|
|
819
|
+
if (exists(currentStatePath(root))) {
|
|
820
|
+
let cs = read(currentStatePath(root));
|
|
821
|
+
cs = cs.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
822
|
+
cs = cs.replace(/## Now\n[\s\S]*?(?=\n## Next)/, `## Now\n- ${(buckets['in-progress'][0]?.request) || '대기 중'}\n`);
|
|
823
|
+
cs = cs.replace(/## Next\n[\s\S]*?(?=\n## Blockers)/, `## Next\n- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}\n`);
|
|
824
|
+
cs = cs.replace(/## Blockers\n[\s\S]*$/, `## Blockers\n${(buckets['blocked']||[]).map(b=>`- ${b.id} ${b.request}`).join('\n') || '-'}\n`);
|
|
825
|
+
writeUtf8(currentStatePath(root), cs);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
append(taskLogPath(root), `\n## ${today()} session-close\n- Generated session-handoff.md and refreshed current-state.md.\n`);
|
|
829
|
+
|
|
830
|
+
log('# Session Close');
|
|
831
|
+
log('## Task Lists');
|
|
832
|
+
for (const s of STATUSES) {
|
|
833
|
+
log(`\n### ${s}`);
|
|
834
|
+
log(rowsToList(buckets[s]));
|
|
835
|
+
}
|
|
836
|
+
log('\n## Required final response sections');
|
|
837
|
+
log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업');
|
|
838
|
+
ok(`session-handoff.md and current-state.md updated`);
|
|
839
|
+
}
|
|
840
|
+
|
|
441
841
|
function readmeCmd(root) { syncReadme(absRoot(root)); }
|
|
442
|
-
function consistencyCheck(root) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
842
|
+
function consistencyCheck(root) {
|
|
843
|
+
root = absRoot(root);
|
|
844
|
+
const cands = ['designguide.md','design-guide.md','.harness/designguide.md','docs/designguide.md','docs/design-guide.md'];
|
|
845
|
+
const found = cands.filter(f => exists(path.join(root,f)));
|
|
846
|
+
log('Canonical design file: .harness/design-system.md');
|
|
847
|
+
if (found.length) { warn('merge candidates found:'); found.forEach(x => log('- ' + x)); }
|
|
848
|
+
else ok('no duplicate design guide candidates');
|
|
849
|
+
}
|
|
850
|
+
function mergeDesign(root) {
|
|
851
|
+
root = absRoot(root);
|
|
852
|
+
const canonical = path.join(root,'.harness/design-system.md');
|
|
853
|
+
const cands = ['designguide.md','design-guide.md','.harness/designguide.md','docs/designguide.md','docs/design-guide.md'];
|
|
854
|
+
let merged = '';
|
|
855
|
+
for (const f of cands) { const p = path.join(root,f); if (exists(p)) merged += `\n\n## Merged from ${f}\n\n` + read(p); }
|
|
856
|
+
if (merged) append(canonical, merged);
|
|
857
|
+
ok(merged ? 'design guides merged into .harness/design-system.md' : 'nothing to merge');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function selfCheck(root) {
|
|
861
|
+
let latest = 'unknown';
|
|
862
|
+
try { latest = cp.execSync('npm view leerness version', { encoding:'utf8', stdio:['ignore','pipe','ignore'], timeout:10000 }).trim(); }
|
|
863
|
+
catch { latest = 'npm registry unavailable'; }
|
|
864
|
+
const verF = path.join(root,'.harness/HARNESS_VERSION');
|
|
865
|
+
const local = exists(verF) ? read(verF).trim() : 'not installed';
|
|
866
|
+
log(`Leerness CLI: ${VERSION}`); log(`Project: ${local}`); log(`NPM latest: ${latest}`);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ===== Auto update =====
|
|
870
|
+
function compareVer(a, b) {
|
|
871
|
+
const sa = String(a || '0').split('.').map(n => parseInt(n || '0', 10));
|
|
872
|
+
const sb = String(b || '0').split('.').map(n => parseInt(n || '0', 10));
|
|
873
|
+
for (let i = 0; i < 3; i++) {
|
|
874
|
+
const x = sa[i] || 0, y = sb[i] || 0;
|
|
875
|
+
if (x > y) return 1;
|
|
876
|
+
if (x < y) return -1;
|
|
877
|
+
}
|
|
878
|
+
return 0;
|
|
879
|
+
}
|
|
880
|
+
function parseHarnessVersion(text) {
|
|
881
|
+
const t = String(text || '').trim();
|
|
882
|
+
// canonical: "1.9.0", legacy plus: "leerness@1.8.0+plus@1.0.1", legacy bare: "1.8.0", legacy "leerness@1.8.0"
|
|
883
|
+
const plus = t.match(/plus@(\d+\.\d+\.\d+)/);
|
|
884
|
+
const baseAt = t.match(/leerness@(\d+\.\d+\.\d+)/);
|
|
885
|
+
const bare = t.match(/^(\d+\.\d+\.\d+)\s*$/);
|
|
886
|
+
return {
|
|
887
|
+
plus: plus ? plus[1] : null,
|
|
888
|
+
base: baseAt ? baseAt[1] : (bare ? bare[1] : null),
|
|
889
|
+
raw: t || '(not installed)'
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function updateCachePath(root) { return path.join(root, '.harness/cache/update-check.json'); }
|
|
893
|
+
function readUpdateCache(root) { try { const p = updateCachePath(root); if (!exists(p)) return null; return JSON.parse(read(p)); } catch { return null; } }
|
|
894
|
+
function writeUpdateCache(root, obj) { writeUtf8(updateCachePath(root), JSON.stringify({ at: Date.now(), ...obj }, null, 2) + '\n'); }
|
|
895
|
+
function cacheFresh(c, hours) { return c && c.at && (Date.now() - c.at < hours * 3600 * 1000); }
|
|
896
|
+
function fetchNpmLatest(pkg) {
|
|
897
|
+
return new Promise(resolve => {
|
|
898
|
+
if (process.env.LEERNESS_OFFLINE === '1' || process.env.LEERNESS_PLUS_OFFLINE === '1') return resolve(null);
|
|
899
|
+
cp.exec(`npm view ${pkg} version`, { timeout: 12000 }, (err, stdout) => {
|
|
900
|
+
if (err) return resolve(null);
|
|
901
|
+
const v = String(stdout || '').trim();
|
|
902
|
+
resolve(/^\d+\.\d+\.\d+/.test(v) ? v : null);
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function updateCmd(root, opts = {}) {
|
|
908
|
+
root = absRoot(root);
|
|
909
|
+
const verF = path.join(root, '.harness/HARNESS_VERSION');
|
|
910
|
+
const cur = exists(verF) ? parseHarnessVersion(read(verF)) : { plus: null, base: null, raw: '(not installed)' };
|
|
911
|
+
log(`# leerness update`);
|
|
912
|
+
log(`Current: ${cur.raw}`);
|
|
913
|
+
const fromTar = arg('--from', null);
|
|
914
|
+
const cacheHours = opts.checkOnly ? 24 : 0;
|
|
915
|
+
let nextLeerness = VERSION;
|
|
916
|
+
if (fromTar) log(`Local tarball mode: ${fromTar}`);
|
|
917
|
+
else {
|
|
918
|
+
const cached = readUpdateCache(root);
|
|
919
|
+
if (cacheFresh(cached, cacheHours)) {
|
|
920
|
+
nextLeerness = cached.nextLeerness || VERSION;
|
|
921
|
+
log(`(cached ${Math.round((Date.now() - cached.at) / 60000)}m ago)`);
|
|
922
|
+
log(`npm leerness latest: ${cached.nextLeerness || '(unavailable)'}`);
|
|
923
|
+
} else {
|
|
924
|
+
log('Checking npm registry…');
|
|
925
|
+
const latest = await fetchNpmLatest('leerness');
|
|
926
|
+
nextLeerness = latest || VERSION;
|
|
927
|
+
writeUpdateCache(root, { nextLeerness: latest, runningCli: VERSION });
|
|
928
|
+
log(`npm leerness latest: ${latest || '(unavailable, using running CLI ' + VERSION + ')'}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// What is "current"? canonical=base; legacy plus also rolls into leerness 1.9.0+
|
|
932
|
+
const installed = cur.base || cur.plus; // either form
|
|
933
|
+
let needsMigrate = false;
|
|
934
|
+
let reason = '';
|
|
935
|
+
if (!installed) { needsMigrate = true; reason = 'first install'; }
|
|
936
|
+
else if (compareVer(nextLeerness, installed) > 0) { needsMigrate = true; reason = `newer (${installed} → ${nextLeerness})`; }
|
|
937
|
+
else if (cur.plus && compareVer(nextLeerness, cur.base || '0.0.0') >= 0) {
|
|
938
|
+
// Legacy plus@x.y.z layout → consolidate into leerness@1.9.0
|
|
939
|
+
if (compareVer(nextLeerness, '1.9.0') >= 0) { needsMigrate = true; reason = 'consolidate legacy plus@ marker into canonical'; }
|
|
940
|
+
}
|
|
941
|
+
if (opts.checkOnly) {
|
|
942
|
+
if (needsMigrate) log(`\n→ migration available: ${reason}`);
|
|
943
|
+
else log('\n→ up to date');
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (!needsMigrate && !opts.force) { ok('already up to date'); return; }
|
|
947
|
+
if (!opts.yes && process.stdin.isTTY) {
|
|
948
|
+
const a = await ask(`Apply migration to ${nextLeerness}? [Y/n] `);
|
|
949
|
+
if (a && /^n/i.test(a)) { log('aborted'); return; }
|
|
950
|
+
}
|
|
951
|
+
const runningIsLatest = compareVer(VERSION, nextLeerness) >= 0 && !fromTar;
|
|
952
|
+
if (!runningIsLatest && !fromTar) {
|
|
953
|
+
log(`\nDelegating to npx leerness@${nextLeerness} migrate (this fetches the new CLI)…`);
|
|
954
|
+
const r = cp.spawnSync('npx', ['-y', `leerness@${nextLeerness}`, 'migrate', root, '--yes'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
955
|
+
if (r.status !== 0) { fail(`delegated migrate exited ${r.status}`); process.exitCode = 1; return; }
|
|
956
|
+
} else if (fromTar) {
|
|
957
|
+
log(`\nDelegating to npx -p ${fromTar} leerness migrate (local tarball)…`);
|
|
958
|
+
const r = cp.spawnSync('npx', ['-y', '-p', fromTar, 'leerness', 'migrate', root, '--yes'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
959
|
+
if (r.status !== 0) { fail(`delegated migrate exited ${r.status}`); process.exitCode = 1; return; }
|
|
960
|
+
} else {
|
|
961
|
+
log(`\nRunning in-process migrate (already on latest ${VERSION})…`);
|
|
962
|
+
await install(root, { force: false, dry: false, migration: true, nonInteractive: true });
|
|
963
|
+
}
|
|
964
|
+
log('\n# Post-migration checks');
|
|
965
|
+
status(root);
|
|
966
|
+
verify(root);
|
|
967
|
+
audit(root);
|
|
968
|
+
append(taskLogPath(root), `\n## ${today()} update\n- Migrated to leerness@${nextLeerness}.\n`);
|
|
969
|
+
append(evidencePath(root), `\n## ${now().slice(0, 16)} leerness update\nCommand: leerness update\nFrom: ${cur.raw}\nTo: leerness@${nextLeerness}\nResult: migrated\n`);
|
|
970
|
+
ok('update complete');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function autoUpdateInstall(root) {
|
|
974
|
+
root = absRoot(root);
|
|
975
|
+
const settingsDir = path.join(root, '.claude');
|
|
976
|
+
mkdirp(settingsDir);
|
|
977
|
+
const settingsFile = path.join(settingsDir, 'settings.local.json');
|
|
978
|
+
let settings = {};
|
|
979
|
+
if (exists(settingsFile)) { try { settings = JSON.parse(read(settingsFile)); } catch {} }
|
|
980
|
+
settings.hooks = settings.hooks || {};
|
|
981
|
+
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
982
|
+
if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness update'))) {
|
|
983
|
+
settings.hooks.SessionStart.push({ matcher: '*', command: 'leerness update --check' });
|
|
984
|
+
}
|
|
985
|
+
writeUtf8(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
986
|
+
writeUtf8(path.join(root, '.claude/commands/update.md'),
|
|
987
|
+
`# /update\n\nleerness 자동 업데이트 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\n체크만:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`);
|
|
988
|
+
ok('auto-update SessionStart hook installed (.claude/settings.local.json)');
|
|
989
|
+
ok('/update slash command added');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ===== ViewWork hook =====
|
|
993
|
+
function viewworkEmit(root, ev) {
|
|
994
|
+
root = absRoot(root);
|
|
995
|
+
const dir = path.join(root, '.viewwork');
|
|
996
|
+
if (!exists(dir)) return;
|
|
997
|
+
const file = path.join(dir, 'agent-events.jsonl');
|
|
998
|
+
const line = JSON.stringify({
|
|
999
|
+
at: Date.now(),
|
|
1000
|
+
agent: ev.agent || 'leerness',
|
|
1001
|
+
agentKind: ev.agentKind || 'system',
|
|
1002
|
+
action: ev.action || 'task',
|
|
1003
|
+
path: ev.path || '/.harness',
|
|
1004
|
+
tool: ev.tool || 'leerness-cli',
|
|
1005
|
+
toolKind: ev.toolKind || 'task',
|
|
1006
|
+
note: ev.note || ''
|
|
1007
|
+
}) + '\n';
|
|
1008
|
+
try { fs.appendFileSync(file, line, 'utf8'); } catch {}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function viewworkInstall(root) {
|
|
1012
|
+
root = absRoot(root);
|
|
1013
|
+
const dir = path.join(root, '.viewwork');
|
|
1014
|
+
mkdirp(dir);
|
|
1015
|
+
if (!exists(path.join(dir, 'agent-events.jsonl'))) writeUtf8(path.join(dir, 'agent-events.jsonl'), '');
|
|
1016
|
+
if (!exists(path.join(dir, 'config.json'))) writeUtf8(path.join(dir, 'config.json'), JSON.stringify({ schemaVersion: 2 }, null, 2) + '\n');
|
|
1017
|
+
if (!exists(path.join(dir, 'version'))) writeUtf8(path.join(dir, 'version'), '2\n');
|
|
1018
|
+
const settingsDir = path.join(root, '.claude');
|
|
1019
|
+
mkdirp(settingsDir);
|
|
1020
|
+
const settingsFile = path.join(settingsDir, 'settings.local.json');
|
|
1021
|
+
let settings = {};
|
|
1022
|
+
if (exists(settingsFile)) { try { settings = JSON.parse(read(settingsFile)); } catch {} }
|
|
1023
|
+
settings.hooks = settings.hooks || {};
|
|
1024
|
+
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
|
1025
|
+
if (!settings.hooks.Stop.some(h => h.command && h.command.includes('leerness viewwork'))) {
|
|
1026
|
+
settings.hooks.Stop.push({ matcher: '*', command: 'leerness viewwork emit . --action task --note "claude session stop"' });
|
|
1027
|
+
}
|
|
1028
|
+
writeUtf8(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
1029
|
+
writeUtf8(path.join(root, '.claude/commands/viewwork-ping.md'),
|
|
1030
|
+
`# /viewwork-ping\n\nViewWork 이벤트를 수동으로 기록합니다.\n\n\`\`\`\n!leerness viewwork emit . --action note --note \"manual ping\"\n\`\`\`\n`);
|
|
1031
|
+
ok('viewwork hook installed');
|
|
1032
|
+
ok('claude .claude/settings.local.json updated (Stop hook adds a viewwork event)');
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function help() {
|
|
1036
|
+
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 [args]\n leerness skill list|info|add <name>\n`);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
448
1039
|
async function main() {
|
|
449
1040
|
const args = nonFlagArgs(); const cmd = args[0] || 'init';
|
|
450
1041
|
if (has('--version') || has('-v')) return log(VERSION);
|
|
451
1042
|
if (has('--help') || has('-h')) return help();
|
|
452
|
-
if (cmd === 'init')
|
|
453
|
-
if (cmd === 'migrate')
|
|
454
|
-
if (cmd === '
|
|
455
|
-
if (cmd === '
|
|
456
|
-
if (cmd === '
|
|
457
|
-
if (cmd === '
|
|
458
|
-
if (cmd === '
|
|
1043
|
+
if (cmd === 'init') return await install(args[1] || process.cwd(), { force:false, dry:false, migration:false });
|
|
1044
|
+
if (cmd === 'migrate') return await install(args[1] || process.cwd(), { force:has('--force'), dry:has('--dry-run'), migration:true });
|
|
1045
|
+
if (cmd === 'update') return await updateCmd(args[1] || process.cwd(), { checkOnly: has('--check'), yes: has('--yes'), force: has('--force') });
|
|
1046
|
+
if (cmd === 'auto-update' && args[1] === 'install') return autoUpdateInstall(args[2] || process.cwd());
|
|
1047
|
+
if (cmd === 'status') return status(args[1] || process.cwd());
|
|
1048
|
+
if (cmd === 'verify') return verify(args[1] || process.cwd());
|
|
1049
|
+
if (cmd === 'debug') return debug(args[1] || process.cwd());
|
|
1050
|
+
if (cmd === 'audit') return audit(args[1] || process.cwd());
|
|
1051
|
+
if (cmd === 'check') return preCheck(args[1] || process.cwd());
|
|
1052
|
+
if (cmd === 'scan' && args[1] === 'secrets') return scanSecrets(args[2] || process.cwd());
|
|
1053
|
+
if (cmd === 'encoding' && args[1] === 'check') return encodingCheck(args[2] || process.cwd());
|
|
1054
|
+
if (cmd === 'lazy' && args[1] === 'detect') return lazyDetect(args[2] || process.cwd());
|
|
1055
|
+
if (cmd === 'memory' && args[1] === 'search') return memorySearch(arg('--path', process.cwd()), args.slice(2).join(' '));
|
|
1056
|
+
if (cmd === 'handoff') return handoff(args[1] || process.cwd());
|
|
1057
|
+
if (cmd === 'session' && args[1] === 'close') { const r = sessionClose(args[2] || process.cwd()); viewworkEmit(args[2] || process.cwd(), { action: 'task', tool: 'session-close', note: 'session close' }); return r; }
|
|
1058
|
+
if (cmd === 'viewwork' && args[1] === 'install') return viewworkInstall(args[2] || process.cwd());
|
|
1059
|
+
if (cmd === 'viewwork' && args[1] === 'emit') return viewworkEmit(args[2] || process.cwd(), { action: arg('--action','task'), note: arg('--note',''), agent: arg('--agent','leerness'), tool: arg('--tool','leerness-cli') });
|
|
1060
|
+
if (cmd === 'route') return route(args[1] || 'planning');
|
|
1061
|
+
if (cmd === 'self' && args[1] === 'check') return selfCheck(absRoot(args[2] || process.cwd()));
|
|
459
1062
|
if (cmd === 'self' && args[1] === 'migrate') return log('Run: npx --yes leerness@latest migrate . --dry-run, then migrate without --dry-run after review.');
|
|
460
|
-
if (cmd === 'readme' && args[1] === 'sync')
|
|
461
|
-
if (cmd === 'consistency' && args[1] === 'check')
|
|
1063
|
+
if (cmd === 'readme' && args[1] === 'sync') return readmeCmd(args[2] || process.cwd());
|
|
1064
|
+
if (cmd === 'consistency' && args[1] === 'check') return consistencyCheck(args[2] || process.cwd());
|
|
462
1065
|
if (cmd === 'consistency' && args[1] === 'merge-design-guide') return mergeDesign(args[2] || process.cwd());
|
|
463
|
-
if (cmd === 'session' && args[1] === 'close') return sessionClose(args[2] || process.cwd());
|
|
464
1066
|
if (cmd === 'skill' && args[1] === 'list') return skillList();
|
|
465
1067
|
if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2]);
|
|
466
|
-
if (cmd === 'skill' && args[1] === 'add')
|
|
467
|
-
if (cmd === 'plan') {
|
|
468
|
-
|
|
469
|
-
|
|
1068
|
+
if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
|
|
1069
|
+
if (cmd === 'plan') {
|
|
1070
|
+
const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'show';
|
|
1071
|
+
if (sub==='show') return planShow(root);
|
|
1072
|
+
if (sub==='init') return planInit(root);
|
|
1073
|
+
if (sub==='add') return planAdd(root, args.slice(2).join(' ') || '새 계획');
|
|
1074
|
+
if (sub==='drop') return planDrop(root, args.slice(2).join(' ') || '드랍 항목');
|
|
1075
|
+
if (sub==='progress') return planProgress(root);
|
|
1076
|
+
if (sub==='sync') return planSync(root);
|
|
1077
|
+
}
|
|
1078
|
+
if (cmd === 'task') {
|
|
1079
|
+
const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'list';
|
|
1080
|
+
if (sub==='list') return taskList(root);
|
|
1081
|
+
if (sub==='add') return taskAdd(root, args.slice(2).join(' ') || '새 작업');
|
|
1082
|
+
if (sub==='update') return taskUpdate(root, args[2]);
|
|
1083
|
+
if (sub==='drop') return taskDrop(root, args[2]);
|
|
1084
|
+
}
|
|
470
1085
|
return help();
|
|
471
1086
|
}
|
|
1087
|
+
|
|
472
1088
|
main().catch(err => { fail(err && err.message ? err.message : String(err)); process.exitCode = 1; });
|