leerness 1.9.7 → 1.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.9 — 2026-05-12
4
+
5
+ **1.9.8 시연 중 자체 도그푸드(dogfood)로 빌드된 패치 — 룰 시스템이 정확히 작동한 증거**.
6
+
7
+ ### Fixed
8
+ - `nonFlagArgs()`의 `withValue` set에 `--trigger`, `--check`, `--set`, `--min-score` 추가. 이전에는 `rule add "..." --trigger every-update` 호출 시 `every-update`가 description 끝에 합쳐져 등록되던 작은 버그.
9
+
10
+ ### Demonstrated (자체 도그푸드)
11
+ - 메인 디렉토리에 사용자 자연어 룰 3종 (버전 bump / CHANGELOG 추가 / 배포 안내) 등록
12
+ - `leerness handoff`가 active rules 자동 노출 ✓
13
+ - `leerness rule pause R-0003` → handoff에서 R-0003 사라짐 ✓
14
+ - `leerness release bump --patch` → 1.9.8 → 1.9.9 자동 ✓
15
+ - `leerness release note "..."` → 이 CHANGELOG 항목 자동 작성 ✓
16
+ - `leerness session close`가 룰 검증 (`✓ pass / ⓘ manual / ○ baseline`) 자동 보고 ✓
17
+
18
+ ## 1.9.8 — 2026-05-08
19
+
20
+ **자연어 룰 등록 + 매 세션 자동 노출·검증 + 코드로 자동화 가능한 release 명령군**.
21
+
22
+ ### Added — User Rules
23
+
24
+ - `.harness/rules.md` — 사용자 정의 영구 룰의 단일 출처. AGENTS.md mandatory read order 10번에 자동 포함.
25
+ - `leerness rule add "<설명>" --trigger every-session|every-update|every-commit|session-start|session-close|pre-publish` — 룰 등록.
26
+ - `leerness rule list / verify / pause <id> / resume <id> / remove <id> / stop / resume-all` — 룰 라이프사이클.
27
+ - `leerness handoff`가 **active rules를 매 세션 시작 시 자동 출력** (사용자 중지 요청 전까지).
28
+ - `leerness session close`가 **활성 룰별 자동 검증 결과 (`✓ pass / ⓘ manual / ⓿ pending / ○ baseline`) 자동 보고**.
29
+
30
+ ### Added — 자연어 룰 처리 지시 (AGENTS.md / CLAUDE.md)
31
+
32
+ 사용자가 자연어로 "매 X마다 Y를 해줘"라고 말하면 AI 에이전트가 즉시 `leerness rule add` 명령을 호출하도록 매핑 표를 추가:
33
+
34
+ | 자연어 | leerness 명령 |
35
+ |---|---|
36
+ | "매 업데이트마다 버전 bump" | `rule add "버전 patch bump" --trigger every-update` |
37
+ | "매 커밋마다 패치노트" | `rule add "패치노트 추가" --trigger every-commit` |
38
+ | "세션 종료마다 배포" | `rule add "배포" --trigger session-close` |
39
+ | "X 룰 중지/그만" | `rule pause <id>` |
40
+ | "X 룰 제거" | `rule remove <id>` |
41
+ | "모든 룰 중지" | `rule stop` |
42
+
43
+ ### Added — release 명령군 (자동화 가능한 룰의 실행 도구)
44
+
45
+ - `leerness release bump [--patch|--minor|--major]` — `package.json#version`과 `.harness/HARNESS_VERSION` 자동 bump.
46
+ - `leerness release note "<내용>"` — CHANGELOG.md에 자동 추가 (같은 버전이면 항목만, 새 버전이면 헤더+항목).
47
+ - `leerness release publish [--dry-run] [--git-push] [--npm-publish]` — npm pack + (선택) git push + (선택) npm publish 통합.
48
+
49
+ ### Added — 룰 자동 검증 휴리스틱
50
+
51
+ `session close`가 매번 자동 수행:
52
+ - **version / 버전 / bump 키워드 룰** → `package.json` version이 baseline 캐시 대비 갱신됐는지.
53
+ - **changelog / 패치노트 키워드 룰** → CHANGELOG.md mtime이 갱신됐는지.
54
+ - **test / 테스트 / verify 키워드 룰** → review-evidence.md에 오늘 verify-code 흔적이 있는지.
55
+ - **deploy / 배포 / publish 키워드 룰** → 자동 검증 불가 → `ⓘ manual` (사용자 안내).
56
+
57
+ ### Migration
58
+
59
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. `.harness/rules.md`는 자동 생성됩니다.
60
+
3
61
  ## 1.9.7 — 2026-05-08
4
62
 
5
63
  코드 검증 자동 실행 + 과거 결정/실수 자동 회수 + TODO 자동 추적의 3종 자동화.
package/bin/harness.js CHANGED
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const readline = require('readline');
8
8
 
9
- const VERSION = '1.9.7';
9
+ const VERSION = '1.9.9';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -56,7 +56,7 @@ function arg(name, def = null) { const i = process.argv.indexOf(name); return i
56
56
  function has(name) { return process.argv.includes(name); }
57
57
  function nonFlagArgs() {
58
58
  const 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','--doc','--command','--capability','--before','--after','--display','--threshold']);
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','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score']);
60
60
  const a = process.argv.slice(2);
61
61
  for (let i = 0; i < a.length; i++) {
62
62
  const x = a[i];
@@ -141,8 +141,8 @@ function coreFiles(root, lang = 'ko', selectedSkills = []) {
141
141
  const project = detectProjectName(root);
142
142
  const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
143
143
  return {
144
- '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`,
145
- '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`,
144
+ 'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## Mandatory read order (session start)\n1. .harness/context-routing.md\n2. .harness/session-handoff.md\n3. .harness/current-state.md\n4. .harness/plan.md\n5. .harness/progress-tracker.md\n6. .harness/guideline.md\n7. .harness/protected-files.md\n8. .harness/writeback-policy.md\n9. .harness/anti-lazy-work-policy.md\n10. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- 작업 분류는 \`leerness route <task-type>\`로 확인합니다 (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- 보호 파일/관리 섹션을 삭제하지 않습니다. 머지·아카이브·deprecated 표시를 사용합니다.\n- 의미 있는 변경 후 progress-tracker, current-state, task-log, session-handoff를 갱신합니다.\n- 완료 선언 전 \`leerness check .\` 또는 \`leerness lazy detect .\`로 자기검증합니다.\n- 변경 전 secret/encoding 가드: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- 같은 기능 중복 생성 전 design-system.md, consistency-policy.md, reuse-map.md를 확인합니다.\n- 매 세션 종료 시 \`leerness session close .\`로 9개 카테고리(완료/진행중/미완료/예정/대기/보류/차단/드랍/검증) + **활성 룰 검증 결과**를 보고합니다.\n- 업데이트는 \`leerness update --check\` (감지) → \`leerness update --yes\` (자동 마이그레이션).\n\n## 자연어 룰 처리 (1.9.8)\n사용자가 자연어로 영구 룰을 요청하면 즉시 leerness rule 명령으로 등록합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "매 업데이트마다 버전 bump해줘" | \`leerness rule add "버전을 patch로 bump" --trigger every-update\` |\n| "매 커밋마다 패치노트 추가해줘" | \`leerness rule add "패치노트 추가" --trigger every-commit\` |\n| "세션 종료마다 배포해줘" | \`leerness rule add "배포 (release publish)" --trigger session-close\` |\n| "X 룰 중지/그만/끄기" | \`leerness rule pause <ID>\` (해당 룰 ID는 list로 확인) |\n| "X 룰 제거/삭제" | \`leerness rule remove <ID>\` |\n| "모든 룰 중지" | \`leerness rule stop\` |\n| "룰 다시 켜줘" | \`leerness rule resume-all\` 또는 \`leerness rule resume <ID>\` |\n\n룰을 등록한 후 사용자에게 등록 결과(ID + trigger + 설명)를 보고하고, 그 이후 매 세션마다 자동 적용합니다. 사용자가 "중지" 또는 "제거"를 명시적으로 말하기 전까지는 룰을 비활성화하지 않습니다.\n\n## 룰 자동 적용 (1.9.8)\nleerness가 자동 검증 가능한 trigger:\n- **every-update / version bump 키워드 룰**: package.json의 version이 갱신됐는지 검사 (handoff/session close가 baseline 캐시와 비교).\n- **CHANGELOG / 패치노트 키워드 룰**: CHANGELOG.md의 mtime이 갱신됐는지 검사.\n- **test / 테스트 / verify 키워드 룰**: review-evidence.md에 오늘 verify-code 흔적이 있는지 검사.\n- **배포 / publish / push 키워드 룰**: 자동 검증 불가 → 사용자에게 release publish 명령을 안내.\n\n자동 검증 가능한 룰의 실행은 \`leerness release bump\`, \`leerness release note "..."\`, \`leerness release publish\`를 사용해 자동화합니다.\n`,
145
+ 'CLAUDE.md': `${MARK}\n# Claude Code Instructions\n\nFollow AGENTS.md. Always run \`leerness handoff .\` at the start and \`leerness session close .\` before ending a session.\n\nProtected files must not be deleted. Read .harness/anti-lazy-work-policy.md before claiming completion.\n\n## 자연어 영구 룰 (1.9.8)\n사용자가 "매 X마다 Y를 해줘" 같은 자연어 룰을 말하면 즉시 \`leerness rule add "Y" --trigger every-X\`로 등록하세요. 등록된 룰은 매 세션 \`handoff\`가 자동 출력하고, \`session close\`가 자동 검증해 보고합니다. 사용자가 "중지" / "그만" / "끄기"를 명시할 때만 \`rule pause/remove\`를 호출합니다.\n\n자세한 매핑은 AGENTS.md의 "자연어 룰 처리" 표를 참고하세요.\n`,
146
146
  '.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`,
147
147
  '.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`,
148
148
  '.harness/HARNESS_VERSION': VERSION + '\n',
@@ -173,6 +173,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = []) {
173
173
  '.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`),
174
174
  '.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`),
175
175
  '.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`),
176
+ '.harness/rules.md': _rulesHeader() + '\n',
176
177
  '.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`),
177
178
  '.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`),
178
179
  '.harness/language-policy.md': fm('language-policy', ['문서 작성 전'], ['언어 변경'], `# Language Policy\n\nSelected language: ${lang}\n\n모든 Leerness 노트, 스킬 노트, 세션 보고, 작업 목록은 위 언어를 기본으로 사용합니다 (사용자가 다른 언어를 명시 요청 시 예외).\n`),
@@ -1111,6 +1112,14 @@ function handoff(root) {
1111
1112
  log('# Session Start Context');
1112
1113
  log(`Date: ${today()}`);
1113
1114
  log(`Project: ${detectProjectName(root)}`);
1115
+ // 1.9.8: active rules 자동 노출 (매 세션 시작 시 AI에게 보임)
1116
+ const activeRules = readRules(root).filter(r => r.status === 'active');
1117
+ if (activeRules.length) {
1118
+ log('');
1119
+ log('## ⚡ Active User Rules (사용자가 명시 중지/제거 요청 전까지 매 세션 자동 노출)');
1120
+ for (const r of activeRules) log(`- ${r.id} [${r.trigger}] ${r.rule} (lastVerified: ${r.lastVerified || '-'})`);
1121
+ log('');
1122
+ }
1114
1123
  log(out);
1115
1124
  if (exists(currentStatePath(root))) {
1116
1125
  const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
@@ -1183,8 +1192,18 @@ function sessionClose(root) {
1183
1192
  log(`\n### ${s}`);
1184
1193
  log(rowsToList(buckets[s]));
1185
1194
  }
1195
+ // 1.9.8: 룰 검증 자동 수행 + 보고
1196
+ const ruleResults = verifyRules(root);
1197
+ log('\n## ⚡ User Rules verification');
1198
+ if (!ruleResults.length) log('- 활성 룰 없음');
1199
+ else {
1200
+ log('| ID | Trigger | Rule | Verified | Note |');
1201
+ log('|---|---|---|---|---|');
1202
+ const ic = { pass: '✓ pass', pending: '⓿ pending', manual: 'ⓘ manual', baseline: '○ baseline' };
1203
+ for (const r of ruleResults) log(`| ${r.id} | ${r.trigger} | ${r.rule.slice(0, 40)} | ${ic[r.verified] || '?'} | ${r.note} |`);
1204
+ }
1186
1205
  log('\n## Required final response sections');
1187
- log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업');
1206
+ log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과');
1188
1207
  ok(`session-handoff.md and current-state.md updated`);
1189
1208
  }
1190
1209
 
@@ -1234,6 +1253,306 @@ function gate(root) {
1234
1253
  else ok('all gates passed');
1235
1254
  }
1236
1255
 
1256
+ // ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
1257
+ function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
1258
+ function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
1259
+ function rulesCachePath(root) { return path.join(root, '.harness/cache/rule-state.json'); }
1260
+
1261
+ function _rulesHeader() {
1262
+ return [
1263
+ '---',
1264
+ 'leernessRole: rules',
1265
+ 'readWhen:',
1266
+ ' - 세션 시작 (handoff)',
1267
+ ' - 매 작업 시작 전',
1268
+ ' - 매 작업 완료 전',
1269
+ ' - 세션 종료 시 (session close)',
1270
+ 'updateWhen:',
1271
+ ' - 사용자가 자연어로 새 룰 요청',
1272
+ ' - 사용자가 룰 중지/제거 요청',
1273
+ 'doNotStore:',
1274
+ ' - 실제 토큰',
1275
+ ' - 비밀번호',
1276
+ ' - 운영 쿠키',
1277
+ ' - 민감한 개인정보 원문',
1278
+ '---',
1279
+ '<!-- leerness:managed -->',
1280
+ '# User Rules',
1281
+ '',
1282
+ '매 세션·매 작업마다 AI 에이전트가 반드시 따라야 할 사용자 정의 영구 룰.',
1283
+ '사용자가 명시적으로 "중지" / "제거"를 요청하기 전까지 모든 active 룰을 매 세션 자동 노출/검증합니다.',
1284
+ '',
1285
+ '## Active Rules',
1286
+ '',
1287
+ '| ID | Trigger | Rule | Added | Status | Last Verified |',
1288
+ '|---|---|---|---|---|---|'
1289
+ ].join('\n');
1290
+ }
1291
+
1292
+ function readRules(root) {
1293
+ const f = rulesPath(root);
1294
+ if (!exists(f)) return [];
1295
+ const rules = [];
1296
+ for (const line of read(f).split('\n')) {
1297
+ if (!/^\| R-\d{4} \|/.test(line)) continue;
1298
+ const cells = line.split('|').slice(1, -1).map(s => s.trim());
1299
+ if (cells.length < 6) continue;
1300
+ rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], added: cells[3], status: cells[4], lastVerified: cells[5] });
1301
+ }
1302
+ return rules;
1303
+ }
1304
+
1305
+ function writeRules(root, rules) {
1306
+ const body = rules.map(r => `| ${r.id} | ${r.trigger} | ${r.rule} | ${r.added} | ${r.status} | ${r.lastVerified || '-'} |`).join('\n');
1307
+ writeUtf8(rulesPath(root), _rulesHeader() + '\n' + body + (body ? '\n' : ''));
1308
+ }
1309
+
1310
+ function nextRuleId(root) {
1311
+ const rules = readRules(root);
1312
+ let max = 0;
1313
+ for (const r of rules) {
1314
+ const m = r.id.match(/^R-(\d{4})$/);
1315
+ if (m) max = Math.max(max, Number(m[1]));
1316
+ }
1317
+ return `R-${String(max + 1).padStart(4, '0')}`;
1318
+ }
1319
+
1320
+ function ruleAdd(root, description) {
1321
+ root = absRoot(root);
1322
+ if (!description) return fail('rule description required (e.g., rule add "매 업데이트마다 버전 bump" --trigger every-update)');
1323
+ if (!exists(rulesPath(root))) writeRules(root, []);
1324
+ const trigger = arg('--trigger', 'every-session');
1325
+ const validTriggers = new Set(['every-session','every-update','every-commit','session-start','session-close','pre-publish']);
1326
+ if (!validTriggers.has(trigger)) {
1327
+ warn(`unknown trigger "${trigger}" — 사용 가능: ${[...validTriggers].join(', ')}. 그대로 등록합니다.`);
1328
+ }
1329
+ const id = nextRuleId(root);
1330
+ const rules = readRules(root);
1331
+ rules.push({ id, trigger, rule: description, added: today(), status: 'active', lastVerified: '-' });
1332
+ writeRules(root, rules);
1333
+ ok(`rule added: ${id} [${trigger}] ${description}`);
1334
+ }
1335
+
1336
+ function ruleList(root) {
1337
+ root = absRoot(root);
1338
+ const rules = readRules(root);
1339
+ if (!rules.length) return ok('등록된 룰 없음');
1340
+ log('| ID | Trigger | Rule | Status | Last Verified |');
1341
+ log('|---|---|---|---|---|');
1342
+ for (const r of rules) log(`| ${r.id} | ${r.trigger} | ${r.rule} | ${r.status} | ${r.lastVerified} |`);
1343
+ }
1344
+
1345
+ function ruleRemove(root, id) {
1346
+ root = absRoot(root);
1347
+ if (!id) return fail('id required');
1348
+ const rules = readRules(root);
1349
+ const i = rules.findIndex(r => r.id === id);
1350
+ if (i < 0) return fail(`rule not found: ${id}`);
1351
+ const removed = rules.splice(i, 1)[0];
1352
+ writeRules(root, rules);
1353
+ const archive = exists(rulesArchivePath(root)) ? read(rulesArchivePath(root)) : '# Rules archive\n\n| ID | Trigger | Rule | Added | Status | Removed |\n|---|---|---|---|---|---|\n';
1354
+ writeUtf8(rulesArchivePath(root), archive + `| ${removed.id} | ${removed.trigger} | ${removed.rule} | ${removed.added} | removed | ${today()} |\n`);
1355
+ ok(`rule removed: ${id} (보존: .harness/rules.archive.md)`);
1356
+ }
1357
+
1358
+ function rulePause(root, id) {
1359
+ root = absRoot(root);
1360
+ if (!id) return fail('id required');
1361
+ const rules = readRules(root);
1362
+ const r = rules.find(x => x.id === id);
1363
+ if (!r) return fail(`rule not found: ${id}`);
1364
+ r.status = 'paused';
1365
+ writeRules(root, rules);
1366
+ ok(`rule paused: ${id}`);
1367
+ }
1368
+
1369
+ function ruleResume(root, id) {
1370
+ root = absRoot(root);
1371
+ if (!id) return fail('id required');
1372
+ const rules = readRules(root);
1373
+ const r = rules.find(x => x.id === id);
1374
+ if (!r) return fail(`rule not found: ${id}`);
1375
+ r.status = 'active';
1376
+ writeRules(root, rules);
1377
+ ok(`rule resumed: ${id}`);
1378
+ }
1379
+
1380
+ function ruleStop(root) {
1381
+ root = absRoot(root);
1382
+ const rules = readRules(root);
1383
+ let n = 0;
1384
+ for (const r of rules) if (r.status === 'active') { r.status = 'paused'; n++; }
1385
+ writeRules(root, rules);
1386
+ ok(`${n}개 룰 일시 정지 (rule resume <id> 또는 rule resume-all로 재개)`);
1387
+ }
1388
+
1389
+ function ruleResumeAll(root) {
1390
+ root = absRoot(root);
1391
+ const rules = readRules(root);
1392
+ let n = 0;
1393
+ for (const r of rules) if (r.status === 'paused') { r.status = 'active'; n++; }
1394
+ writeRules(root, rules);
1395
+ ok(`${n}개 룰 재개`);
1396
+ }
1397
+
1398
+ function captureProjectState(root) {
1399
+ const state = { capturedAt: now() };
1400
+ const pkgFile = path.join(root, 'package.json');
1401
+ if (exists(pkgFile)) { try { state.packageVersion = JSON.parse(read(pkgFile)).version; } catch {} }
1402
+ const cl = path.join(root, 'CHANGELOG.md');
1403
+ if (exists(cl)) { try { state.changelogMtime = fs.statSync(cl).mtime.getTime(); state.changelogSize = fs.statSync(cl).size; } catch {} }
1404
+ const hv = path.join(root, '.harness/HARNESS_VERSION');
1405
+ if (exists(hv)) state.harnessVersion = read(hv).trim();
1406
+ return state;
1407
+ }
1408
+
1409
+ function verifyRules(root) {
1410
+ root = absRoot(root);
1411
+ const rules = readRules(root);
1412
+ const active = rules.filter(r => r.status === 'active');
1413
+ if (!active.length) return [];
1414
+ let prev = {};
1415
+ if (exists(rulesCachePath(root))) { try { prev = JSON.parse(read(rulesCachePath(root))); } catch {} }
1416
+ const cur = captureProjectState(root);
1417
+ const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1418
+ const todayStr = today();
1419
+ const results = [];
1420
+ for (const r of active) {
1421
+ let verified = 'manual';
1422
+ let note = '';
1423
+ const rl = r.rule.toLowerCase();
1424
+ if (/version|버전|bump|상승/i.test(rl)) {
1425
+ if (prev.packageVersion && cur.packageVersion && prev.packageVersion !== cur.packageVersion) {
1426
+ verified = 'pass'; note = `${prev.packageVersion} → ${cur.packageVersion}`;
1427
+ } else if (!prev.packageVersion) {
1428
+ verified = 'baseline'; note = `초기 ${cur.packageVersion || '미확인'}`;
1429
+ } else {
1430
+ verified = 'pending'; note = '버전 변경 없음';
1431
+ }
1432
+ } else if (/changelog|패치노트|patch.*note|note.*추가|note.*add/i.test(rl)) {
1433
+ if (prev.changelogMtime && cur.changelogMtime && cur.changelogMtime > prev.changelogMtime) {
1434
+ verified = 'pass'; note = 'CHANGELOG.md 갱신 감지';
1435
+ } else if (!prev.changelogMtime) {
1436
+ verified = 'baseline'; note = '초기 측정';
1437
+ } else {
1438
+ verified = 'pending'; note = 'CHANGELOG.md 변경 없음';
1439
+ }
1440
+ } else if (/test|테스트|verify/i.test(rl)) {
1441
+ const hasTest = new RegExp(`## ${todayStr}.*verify-code|## ${todayStr}.*test`, 'i').test(ev);
1442
+ verified = hasTest ? 'pass' : 'pending';
1443
+ note = hasTest ? '오늘 verify-code 흔적' : '오늘 verify-code 호출 없음';
1444
+ } else if (/deploy|배포|publish|push|release/i.test(rl)) {
1445
+ verified = 'manual'; note = '배포는 사용자 명시 호출 (leerness release publish)';
1446
+ } else {
1447
+ verified = 'manual'; note = '자동 검증 패턴 없음 — 수동 확인';
1448
+ }
1449
+ results.push({ ...r, verified, note });
1450
+ }
1451
+ // lastVerified 갱신 (pass인 경우만)
1452
+ for (const r of rules) {
1453
+ const m = results.find(x => x.id === r.id);
1454
+ if (m && m.verified === 'pass') r.lastVerified = todayStr;
1455
+ }
1456
+ writeRules(root, rules);
1457
+ writeUtf8(rulesCachePath(root), JSON.stringify(cur, null, 2));
1458
+ return results;
1459
+ }
1460
+
1461
+ function ruleVerifyCmd(root) {
1462
+ root = absRoot(root);
1463
+ const results = verifyRules(root);
1464
+ if (!results.length) return ok('활성 룰 없음');
1465
+ log('# Rules verification');
1466
+ log('| ID | Trigger | Rule | Verified | Note |');
1467
+ log('|---|---|---|---|---|');
1468
+ const ic = { pass: '✓ pass', pending: '⓿ pending', manual: 'ⓘ manual', baseline: '○ baseline' };
1469
+ for (const r of results) log(`| ${r.id} | ${r.trigger} | ${r.rule.slice(0, 40)} | ${ic[r.verified] || '?'} | ${r.note} |`);
1470
+ }
1471
+
1472
+ // ===== 1.9.8: release bump / note / publish =====
1473
+ function releaseBump(root) {
1474
+ root = absRoot(root);
1475
+ const kind = has('--major') ? 'major' : (has('--minor') ? 'minor' : 'patch');
1476
+ const pkgFile = path.join(root, 'package.json');
1477
+ if (!exists(pkgFile)) return fail('package.json 없음');
1478
+ let pkg; try { pkg = JSON.parse(read(pkgFile)); } catch (e) { return fail('package.json 파싱 실패: ' + e.message); }
1479
+ const cur = String(pkg.version || '0.0.0');
1480
+ const parts = cur.split('.').map(n => parseInt(n, 10) || 0);
1481
+ const [maj, min, pat] = [parts[0]||0, parts[1]||0, parts[2]||0];
1482
+ let next;
1483
+ if (kind === 'major') next = `${maj + 1}.0.0`;
1484
+ else if (kind === 'minor') next = `${maj}.${min + 1}.0`;
1485
+ else next = `${maj}.${min}.${pat + 1}`;
1486
+ pkg.version = next;
1487
+ writeUtf8(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
1488
+ const hv = path.join(root, '.harness/HARNESS_VERSION');
1489
+ if (exists(hv) && /^\d+\.\d+\.\d+/.test(read(hv).trim())) writeUtf8(hv, next + '\n');
1490
+ ok(`version bumped: ${cur} → ${next} (${kind})`);
1491
+ }
1492
+
1493
+ function releaseNote(root, text) {
1494
+ root = absRoot(root);
1495
+ if (!text) return fail('note text required (e.g., release note "내용")');
1496
+ const pkgFile = path.join(root, 'package.json');
1497
+ let version = 'unknown';
1498
+ if (exists(pkgFile)) { try { version = JSON.parse(read(pkgFile)).version || 'unknown'; } catch {} }
1499
+ const clFile = path.join(root, 'CHANGELOG.md');
1500
+ const date = today();
1501
+ const headerRe = new RegExp(`^## ${version.replace(/\./g, '\\.')} — `, 'm');
1502
+ if (exists(clFile)) {
1503
+ const cur = read(clFile);
1504
+ if (headerRe.test(cur)) {
1505
+ // 같은 버전 헤더가 있으면 그 바로 아래에 줄 추가
1506
+ const m = cur.match(headerRe);
1507
+ const headerEnd = cur.indexOf('\n', m.index + m[0].length);
1508
+ const insertPos = headerEnd + 1;
1509
+ // 헤더 다음 빈 줄 후 첫 list 시작 찾기
1510
+ const beforeBlock = cur.slice(insertPos);
1511
+ const linesAfter = beforeBlock.split('\n');
1512
+ // 가장 단순: 헤더 다음 줄에 즉시 - text 삽입
1513
+ writeUtf8(clFile, cur.slice(0, insertPos) + `\n- ${text}\n` + cur.slice(insertPos));
1514
+ } else {
1515
+ // 새 버전 헤더 추가 (# Changelog 다음)
1516
+ const top = cur.indexOf('# Changelog');
1517
+ const newBlock = `\n## ${version} — ${date}\n\n- ${text}\n`;
1518
+ if (top >= 0) {
1519
+ const after = cur.indexOf('\n', top) + 1;
1520
+ writeUtf8(clFile, cur.slice(0, after) + newBlock + cur.slice(after));
1521
+ } else {
1522
+ writeUtf8(clFile, `# Changelog\n${newBlock}\n${cur}`);
1523
+ }
1524
+ }
1525
+ } else {
1526
+ writeUtf8(clFile, `# Changelog\n\n## ${version} — ${date}\n\n- ${text}\n`);
1527
+ }
1528
+ ok(`CHANGELOG.md 갱신: [${version}] ${text}`);
1529
+ }
1530
+
1531
+ function releasePublish(root) {
1532
+ root = absRoot(root);
1533
+ const dryRun = has('--dry-run');
1534
+ log('# release publish');
1535
+ log(`Mode: ${dryRun ? 'dry-run' : 'live'}`);
1536
+ const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
1537
+ if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
1538
+ ok('npm pack 완료');
1539
+ if (has('--git-push')) {
1540
+ log('git push:');
1541
+ const r1 = cp.spawnSync('git', ['push'], { cwd: root, encoding: 'utf8', shell: true });
1542
+ log(r1.stdout || r1.stderr || '(no output)');
1543
+ const r2 = cp.spawnSync('git', ['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
1544
+ log(r2.stdout || r2.stderr || '(no output)');
1545
+ }
1546
+ if (has('--npm-publish')) {
1547
+ const args = dryRun ? ['publish', '--dry-run'] : ['publish', '--access', 'public'];
1548
+ log('npm ' + args.join(' '));
1549
+ const r = cp.spawnSync('npm', args, { cwd: root, encoding: 'utf8', shell: true });
1550
+ log((r.stdout || '').split('\n').slice(-5).join('\n'));
1551
+ if (r.status !== 0) { fail('npm publish 실패'); process.exitCode = 1; return; }
1552
+ }
1553
+ ok('release publish 완료');
1554
+ }
1555
+
1237
1556
  // ===== 1.9.7 A: verify-code — npm scripts 자동 감지 + evidence 자동 기록 =====
1238
1557
  function verifyCodeCmd(root) {
1239
1558
  root = absRoot(root);
@@ -1754,7 +2073,12 @@ function help() {
1754
2073
  log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
1755
2074
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
1756
2075
  leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
1757
- leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
2076
+ leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
2077
+ leerness rule add "<설명>" --trigger every-session|every-update|every-commit|session-start|session-close|pre-publish # 사용자 룰 등록 (1.9.8)
2078
+ leerness rule list|verify|pause <id>|resume <id>|remove <id>|stop|resume-all
2079
+ leerness release bump [--patch|--minor|--major] # package.json 자동 bump (1.9.8)
2080
+ leerness release note "<내용>" # CHANGELOG.md 자동 추가 (1.9.8)
2081
+ leerness release publish [--dry-run] [--git-push] [--npm-publish] # 통합 배포 (1.9.8)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
1758
2082
  }
1759
2083
 
1760
2084
  async function main() {
@@ -1795,6 +2119,17 @@ async function main() {
1795
2119
  if (cmd === 'gate') return gate(args[1] || process.cwd());
1796
2120
  if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
1797
2121
  if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
2122
+ if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2123
+ if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
2124
+ if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
2125
+ if (cmd === 'rule' && args[1] === 'pause') return rulePause(arg('--path', process.cwd()), args[2]);
2126
+ if (cmd === 'rule' && args[1] === 'resume') return ruleResume(arg('--path', process.cwd()), args[2]);
2127
+ if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
2128
+ if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
2129
+ if (cmd === 'rule' && args[1] === 'verify') return ruleVerifyCmd(arg('--path', process.cwd()));
2130
+ if (cmd === 'release' && args[1] === 'bump') return releaseBump(arg('--path', process.cwd()));
2131
+ if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2132
+ if (cmd === 'release' && args[1] === 'publish') return releasePublish(arg('--path', process.cwd()));
1798
2133
  if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
1799
2134
  if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
1800
2135
  if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.7",
3
+ "version": "1.9.9",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -235,6 +235,91 @@ total++;
235
235
  if (!(strongOK && weakHint)) failed++;
236
236
  }
237
237
 
238
+ // 1.9.8: rule add/list/pause/resume/remove
239
+ total++;
240
+ {
241
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'rule', 'add', '매 업데이트마다 버전 patch bump', '--trigger', 'every-update', '--path', tmp], { encoding: 'utf8' });
242
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'rule', 'add', '매 업데이트마다 패치노트 추가', '--trigger', 'every-update', '--path', tmp], { encoding: 'utf8' });
243
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'rule', 'add', '세션 종료마다 배포', '--trigger', 'session-close', '--path', tmp], { encoding: 'utf8' });
244
+ const rl = cp.spawnSync(process.execPath, [CLI, 'rule', 'list', '--path', tmp], { encoding: 'utf8' });
245
+ const ok = r1.status === 0 && r2.status === 0 && r3.status === 0 && /R-0001/.test(rl.stdout) && /R-0003/.test(rl.stdout);
246
+ console.log(ok ? '✓ B(1.9.8) rule add/list: 3개 등록' : '✗ B(1.9.8) rule add/list 실패');
247
+ if (!ok) failed++;
248
+ }
249
+ total++;
250
+ {
251
+ // pause + handoff에서 paused는 안 보여야 함
252
+ cp.spawnSync(process.execPath, [CLI, 'rule', 'pause', 'R-0001', '--path', tmp], { encoding: 'utf8' });
253
+ const hr = cp.spawnSync(process.execPath, [CLI, 'handoff', tmp], { encoding: 'utf8' });
254
+ const ok = /Active User Rules/.test(hr.stdout) && /R-0002/.test(hr.stdout) && /R-0003/.test(hr.stdout) && !/R-0001 \[/.test(hr.stdout);
255
+ console.log(ok ? '✓ B(1.9.8) handoff: paused 룰 제외, active만 출력' : '✗ B(1.9.8) handoff 출력 실패');
256
+ if (!ok) { failed++; console.log(hr.stdout); }
257
+ cp.spawnSync(process.execPath, [CLI, 'rule', 'resume', 'R-0001', '--path', tmp], { encoding: 'utf8' });
258
+ }
259
+ total++;
260
+ {
261
+ // rule stop / resume-all
262
+ cp.spawnSync(process.execPath, [CLI, 'rule', 'stop', '--path', tmp], { encoding: 'utf8' });
263
+ const rl = cp.spawnSync(process.execPath, [CLI, 'rule', 'list', '--path', tmp], { encoding: 'utf8' });
264
+ const allPaused = (rl.stdout.match(/\| paused \|/g) || []).length >= 3;
265
+ cp.spawnSync(process.execPath, [CLI, 'rule', 'resume-all', '--path', tmp], { encoding: 'utf8' });
266
+ const rl2 = cp.spawnSync(process.execPath, [CLI, 'rule', 'list', '--path', tmp], { encoding: 'utf8' });
267
+ const allActive = (rl2.stdout.match(/\| active \|/g) || []).length >= 3;
268
+ console.log(allPaused && allActive ? '✓ B(1.9.8) rule stop / resume-all: 일괄 전환' : '✗ B(1.9.8) stop/resume-all 실패');
269
+ if (!(allPaused && allActive)) failed++;
270
+ }
271
+
272
+ // 1.9.8: release bump
273
+ total++;
274
+ {
275
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rel-'));
276
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
277
+ fs.writeFileSync(path.join(tmpR, 'package.json'), JSON.stringify({ name: 't', version: '1.0.0' }));
278
+ cp.spawnSync(process.execPath, [CLI, 'release', 'bump', '--patch', '--path', tmpR], { encoding: 'utf8' });
279
+ let v = JSON.parse(fs.readFileSync(path.join(tmpR, 'package.json'), 'utf8')).version;
280
+ const okPatch = v === '1.0.1';
281
+ cp.spawnSync(process.execPath, [CLI, 'release', 'bump', '--minor', '--path', tmpR], { encoding: 'utf8' });
282
+ v = JSON.parse(fs.readFileSync(path.join(tmpR, 'package.json'), 'utf8')).version;
283
+ const okMinor = v === '1.1.0';
284
+ cp.spawnSync(process.execPath, [CLI, 'release', 'bump', '--major', '--path', tmpR], { encoding: 'utf8' });
285
+ v = JSON.parse(fs.readFileSync(path.join(tmpR, 'package.json'), 'utf8')).version;
286
+ const okMajor = v === '2.0.0';
287
+ console.log(okPatch && okMinor && okMajor ? '✓ B(1.9.8) release bump: patch/minor/major' : `✗ B(1.9.8) bump 실패 final=${v}`);
288
+ if (!(okPatch && okMinor && okMajor)) failed++;
289
+ }
290
+
291
+ // 1.9.8: release note → CHANGELOG.md 자동 갱신
292
+ total++;
293
+ {
294
+ const tmpN = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-note-'));
295
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpN, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
296
+ fs.writeFileSync(path.join(tmpN, 'package.json'), JSON.stringify({ name: 't', version: '0.1.0' }));
297
+ cp.spawnSync(process.execPath, [CLI, 'release', 'note', '첫 기능 추가', '--path', tmpN], { encoding: 'utf8' });
298
+ const cl = fs.readFileSync(path.join(tmpN, 'CHANGELOG.md'), 'utf8');
299
+ const ok = /## 0\.1\.0/.test(cl) && /첫 기능 추가/.test(cl);
300
+ console.log(ok ? '✓ B(1.9.8) release note: CHANGELOG 자동 갱신' : '✗ B(1.9.8) release note 실패');
301
+ if (!ok) failed++;
302
+ }
303
+
304
+ // 1.9.8: session close가 rule verification 보고
305
+ total++;
306
+ {
307
+ // tmp는 위에서 rule 3개 등록됨
308
+ // package.json 만들기 + 버전 변경 시뮬 (rule R-0001은 every-update 버전 룰)
309
+ fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify({ name: 'tmp-e2e', version: '0.1.0' }));
310
+ // 첫 session close — baseline 캡처
311
+ cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmp], { encoding: 'utf8' });
312
+ // 버전 bump
313
+ cp.spawnSync(process.execPath, [CLI, 'release', 'bump', '--patch', '--path', tmp], { encoding: 'utf8' });
314
+ // CHANGELOG 갱신
315
+ cp.spawnSync(process.execPath, [CLI, 'release', 'note', 'e2e 검증 항목 추가', '--path', tmp], { encoding: 'utf8' });
316
+ // 두 번째 session close — 변경 감지
317
+ const sc = cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmp], { encoding: 'utf8' });
318
+ const ok = /User Rules verification/.test(sc.stdout) && /✓ pass/.test(sc.stdout);
319
+ console.log(ok ? '✓ B(1.9.8) session close: rule 검증 ✓ pass 출력' : `✗ B(1.9.8) session close 검증 실패\n${sc.stdout.split('\n').slice(-15).join('\n')}`);
320
+ if (!ok) failed++;
321
+ }
322
+
238
323
  // 1.9.7 A: verify-code — 가짜 package.json + 통과 시나리오
239
324
  total++;
240
325
  {