leerness 1.9.5 → 1.9.7

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.7 — 2026-05-08
4
+
5
+ 코드 검증 자동 실행 + 과거 결정/실수 자동 회수 + TODO 자동 추적의 3종 자동화.
6
+
7
+ ### Added — A. `leerness verify-code [path] [--build]`
8
+
9
+ `package.json#scripts`에서 `test` / `lint` / `typecheck` (또는 `tsc`) / (선택) `build`를 자동 감지해 차례로 실행. 결과는 모두 `review-evidence.md`에 자동 누적 (`Command/Tasks/exit/duration/tail`). 실패 시 `process.exit(1)` + progress의 in-progress row를 `incomplete`로 표시 권장 안내.
10
+
11
+ - `tsconfig.json`이 있고 `typecheck` script가 없으면 `npx tsc --noEmit` 자동 호출.
12
+ - 5분 timeout 내장 (장기 실행 방지).
13
+
14
+ ### Added — B. `leerness lessons [--query <키>] [--limit N]`
15
+
16
+ `decisions.md`의 모든 `### 블록`, `review-evidence.md`의 실패 표지(`✗ / fail / 롤백 / incomplete / bug / 버그 / warning`) 블록, `task-log.md`의 실패 키워드 라인, `session-handoff.md`의 Incomplete 섹션을 통합 추출. `--query`로 키워드 필터.
17
+
18
+ - `leerness guide [target]`이 자동으로 lessons 섹션을 추가 (target 이름을 query로 사용).
19
+
20
+ ### Added — C. `lazy detect --auto-track` + `.harness/known-todos.json`
21
+
22
+ 새 TODO/FIXME/XXX의 `(file, line, text)` 위치 캡처. `known-todos.json`에 acknowledged 기록을 비교해 매번 같은 false positive를 줄이고, 새로 발견된 것만 노출. `--auto-track`으로 `progress-tracker.md`에 `T-XXXX requested`로 자동 등록 + known-todos.json에도 자동 추가.
23
+
24
+ ### Migration
25
+
26
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
27
+
28
+ ## 1.9.6 — 2026-05-08
29
+
30
+ 1.9.5 후 발견된 한 가지 한계 (옛 link 손실 자동 복구 부재)를 패치.
31
+
32
+ ### Added
33
+
34
+ - **`leerness task relink [--apply] [--min-score 0.2]`** — `plan.md`의 milestone 텍스트와 `progress-tracker.md`의 task `request` 텍스트를 jaccard 토큰 유사도로 비교해 미연결 milestone을 가장 비슷한 row와 자동 매칭. default는 제안만 출력 (사용자가 명령 복사해 실행), `--apply`로 자동 적용. `--min-score`로 임계 조정 (기본 0.2).
35
+ - **`audit`이 미연결 milestone 발견 시 `leerness task relink` 안내 자동 출력**.
36
+
37
+ ### Migration
38
+
39
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
40
+
3
41
  ## 1.9.5 — 2026-05-08
4
42
 
5
43
  1.9.4 운영 중 발견된 한계 2건 + 추가 디버그 사항을 패치합니다.
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.5';
9
+ const VERSION = '1.9.7';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -679,6 +679,67 @@ function taskDrop(root, id) {
679
679
  ok(`task dropped: ${id}`);
680
680
  }
681
681
 
682
+ // 1.9.6: 옛 link 손실 row를 plan.md milestone과 자동 매칭 제안/복구.
683
+ function _tokenizeForSim(s) {
684
+ // unicode letter/number만 보존 — \W는 ASCII 기준이라 한글이 분리되는 버그 회피
685
+ return new Set(
686
+ String(s || '').toLowerCase()
687
+ .split(/\s+/)
688
+ .map(t => t.replace(/[^\p{L}\p{N}_]+/gu, ''))
689
+ .filter(t => t.length >= 2)
690
+ );
691
+ }
692
+ function _jaccard(a, b) {
693
+ const inter = new Set([...a].filter(x => b.has(x))).size;
694
+ const uni = new Set([...a, ...b]).size;
695
+ return uni ? inter / uni : 0;
696
+ }
697
+
698
+ function taskRelink(root) {
699
+ root = absRoot(root);
700
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
701
+ const milestones = [...planText.matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)]
702
+ .map(m => ({ id: m[1], text: m[2].trim() }));
703
+ const rows = readProgressRows(root);
704
+ const linkedM = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
705
+ const orphanM = milestones.filter(m => !linkedM.has(m.id));
706
+ if (!orphanM.length) return ok('미연결 milestone 없음');
707
+
708
+ const apply = has('--apply');
709
+ const minScore = parseFloat(arg('--min-score', '0.2'));
710
+ log(`# task relink — 미연결 milestone ${orphanM.length}개${apply ? ' (--apply: 자동 적용)' : ' (제안만, --apply로 적용)'}`);
711
+ const suggestions = [];
712
+ for (const m of orphanM) {
713
+ const milestoneTokens = _tokenizeForSim(m.text);
714
+ const candidates = rows
715
+ .map(r => ({ r, score: _jaccard(milestoneTokens, _tokenizeForSim(r.request)) }))
716
+ .filter(x => x.score >= minScore)
717
+ .sort((a, b) => b.score - a.score);
718
+ log(`\n${m.id}: ${m.text}`);
719
+ if (!candidates.length) {
720
+ log(` ⓘ 매칭 후보 없음 (score ≥ ${minScore})`);
721
+ log(` → 새 task: leerness task add "${m.text}" --status planned --evidence "plan:${m.id}"`);
722
+ continue;
723
+ }
724
+ const best = candidates[0];
725
+ const newEv = best.r.evidence.includes(`plan:${m.id}`) ? best.r.evidence : `${best.r.evidence} (plan:${m.id})`;
726
+ log(` ✓ 최선 후보: ${best.r.id} (score ${best.score.toFixed(2)}) — ${best.r.request}`);
727
+ log(` 현재 evidence: "${best.r.evidence}"`);
728
+ log(` 제안 evidence: "${newEv}"`);
729
+ log(` 수동: leerness task update ${best.r.id} --evidence "${newEv}"`);
730
+ if (candidates.length > 1) {
731
+ const next = candidates.slice(1, 3).map(c => `${c.r.id}(${c.score.toFixed(2)})`).join(', ');
732
+ log(` 다른 후보: ${next}`);
733
+ }
734
+ suggestions.push({ id: best.r.id, evidence: newEv });
735
+ }
736
+ if (apply && suggestions.length) {
737
+ for (const s of suggestions) upsertProgress(root, { id: s.id, evidence: s.evidence });
738
+ log('');
739
+ ok(`${suggestions.length}개 row 자동 relink 완료`);
740
+ }
741
+ }
742
+
682
743
  // 1.9.4 D: evidence가 placeholder인 done row를 일괄 점검.
683
744
  function taskFixEvidence(root) {
684
745
  root = absRoot(root);
@@ -786,9 +847,17 @@ function audit(root) {
786
847
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
787
848
  const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
788
849
  const rows = readProgressRows(root);
789
- const linkedMs = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
850
+ // 1.9.6 수정: row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
851
+ const linkedMs = new Set(
852
+ rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
853
+ );
790
854
  const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
791
- if (missingFromProgress.length) { warnings++; warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`); }
855
+ if (missingFromProgress.length) {
856
+ warnings++;
857
+ warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
858
+ log(` → 자동 매칭 제안: leerness task relink`);
859
+ log(` → 자동 적용: leerness task relink --apply`);
860
+ }
792
861
  else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
793
862
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
794
863
  if (handoff.includes('Last generated: (자동)')) { warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)'); }
@@ -934,7 +1003,13 @@ function lazyDetect(root) {
934
1003
  const bq = (pre.match(/(?<!\\)`/g) || []).length;
935
1004
  return (sq % 2 === 1) || (dq % 2 === 1) || (bq % 2 === 1);
936
1005
  }
1006
+ // 1.9.7 C: TODO 자동 추적 강화 — 위치+텍스트 캡처, known-todos 비교, --auto-track 등록
1007
+ const knownPath = path.join(root, '.harness/known-todos.json');
1008
+ let knownList = [];
1009
+ if (exists(knownPath)) { try { knownList = JSON.parse(read(knownPath)); } catch {} }
1010
+ const knownSet = new Set(knownList.map(k => `${k.file}:${k.line}:${k.text}`));
937
1011
  let todoCount = 0;
1012
+ const newTodos = [];
938
1013
  const cliSelf = path.resolve(__filename);
939
1014
  for (const file of walk(root)) {
940
1015
  const ext = path.extname(file).toLowerCase();
@@ -945,17 +1020,39 @@ function lazyDetect(root) {
945
1020
  let text; try { text = read(file); } catch { continue; }
946
1021
  const lines = text.split('\n');
947
1022
  const tre = /\bTODO\b|\bFIXME\b|\bXXX\b/g;
948
- for (const line of lines) {
1023
+ for (let i = 0; i < lines.length; i++) {
949
1024
  tre.lastIndex = 0;
950
1025
  let m;
951
- while ((m = tre.exec(line))) {
952
- if (!isInsideQuote(line, m.index)) todoCount++;
1026
+ while ((m = tre.exec(lines[i]))) {
1027
+ if (isInsideQuote(lines[i], m.index)) continue;
1028
+ todoCount++;
1029
+ const txt = lines[i].trim().slice(0, 120);
1030
+ const fileRel = rel(root, file);
1031
+ const key = `${fileRel}:${i + 1}:${txt}`;
1032
+ if (!knownSet.has(key)) newTodos.push({ file: fileRel, line: i + 1, text: txt });
953
1033
  }
954
1034
  }
955
1035
  }
956
1036
  if (todoCount > 0) {
957
1037
  const hasTodoTask = rows.some(r => /TODO|FIXME|XXX/.test(r.request) || /TODO|FIXME|XXX/i.test(r.evidence));
958
- if (!hasTodoTask) { issues++; warn(`code has ${todoCount} TODO/FIXME/XXX but no progress-tracker entry tracks them`); }
1038
+ if (!hasTodoTask) {
1039
+ issues++;
1040
+ warn(`code has ${todoCount} TODO/FIXME/XXX (new: ${newTodos.length}) but no progress-tracker entry tracks them`);
1041
+ // 새 TODO 처음 5개 표시
1042
+ newTodos.slice(0, 5).forEach(t => log(` ${t.file}:${t.line} ${t.text}`));
1043
+ if (has('--auto-track') && newTodos.length) {
1044
+ for (const t of newTodos) {
1045
+ const id = nextId(root, 'T');
1046
+ upsertProgress(root, { id, status: 'requested', request: `TODO ${t.file}:${t.line}`, evidence: 'auto-tracked', nextAction: t.text.slice(0, 80) });
1047
+ }
1048
+ // known-todos에 추가 — 다음 detect에서 재카운트 안 하도록
1049
+ const merged = [...knownList, ...newTodos.map(t => ({ ...t, ackAt: now() }))];
1050
+ writeUtf8(knownPath, JSON.stringify(merged, null, 2) + '\n');
1051
+ ok(`${newTodos.length}개 TODO를 progress-tracker에 자동 등록 + known-todos.json 갱신`);
1052
+ } else if (newTodos.length) {
1053
+ log(` 💡 자동 등록: leerness lazy detect --auto-track`);
1054
+ }
1055
+ }
959
1056
  }
960
1057
  const blockers = rows.filter(r => r.status === 'blocked');
961
1058
  for (const b of blockers) if (b.nextAction === '없음' || /다음 액션 작성/.test(b.nextAction)) { issues++; warn(`blocker without nextAction: ${b.id}`); }
@@ -1137,6 +1234,114 @@ function gate(root) {
1137
1234
  else ok('all gates passed');
1138
1235
  }
1139
1236
 
1237
+ // ===== 1.9.7 A: verify-code — npm scripts 자동 감지 + evidence 자동 기록 =====
1238
+ function verifyCodeCmd(root) {
1239
+ root = absRoot(root);
1240
+ const pkgFile = path.join(root, 'package.json');
1241
+ if (!exists(pkgFile)) return fail('package.json 없음 — Node 프로젝트 위치에서 실행하세요.');
1242
+ let pkg;
1243
+ try { pkg = JSON.parse(read(pkgFile)); } catch (e) { return fail('package.json 파싱 실패: ' + e.message); }
1244
+ const scripts = pkg.scripts || {};
1245
+ const tasks = [];
1246
+ if (scripts.test) tasks.push({ name: 'test', cmd: 'npm test' });
1247
+ else if (scripts['test:smoke']) tasks.push({ name: 'test', cmd: 'npm run test:smoke' });
1248
+ if (scripts.lint) tasks.push({ name: 'lint', cmd: 'npm run lint' });
1249
+ if (scripts.typecheck) tasks.push({ name: 'typecheck', cmd: 'npm run typecheck' });
1250
+ else if (scripts.tsc) tasks.push({ name: 'typecheck', cmd: 'npm run tsc' });
1251
+ else if (exists(path.join(root, 'tsconfig.json'))) tasks.push({ name: 'typecheck', cmd: 'npx --yes tsc --noEmit', optional: true });
1252
+ if (has('--build') && scripts.build) tasks.push({ name: 'build', cmd: 'npm run build' });
1253
+ if (!tasks.length) {
1254
+ warn('실행할 검증 task 없음 (package.json#scripts에 test/lint/typecheck 추가하세요)');
1255
+ return;
1256
+ }
1257
+ log(`# verify-code (${tasks.length}개)`);
1258
+ let failedCnt = 0;
1259
+ const results = [];
1260
+ for (const t of tasks) {
1261
+ log(`\n## ${t.name}: ${t.cmd}`);
1262
+ const start = Date.now();
1263
+ const r = cp.spawnSync(t.cmd, [], { cwd: root, encoding: 'utf8', shell: true, timeout: 5 * 60 * 1000 });
1264
+ const dur = Date.now() - start;
1265
+ if (r.status === 0) ok(`${t.name} passed (${dur}ms)`);
1266
+ else if (t.optional && r.status === 127) warn(`${t.name} 스킵 (${t.cmd} 없음)`);
1267
+ else { fail(`${t.name} failed (exit ${r.status}, ${dur}ms)`); failedCnt++; }
1268
+ const tail = (r.stdout || '').split('\n').slice(-8).join('\n').slice(0, 400);
1269
+ results.push({ name: t.name, cmd: t.cmd, exit: r.status, durMs: dur, tail });
1270
+ }
1271
+ const evBlock = [
1272
+ ``,
1273
+ `## ${now().slice(0, 16)} verify-code (자동)`,
1274
+ `Command: leerness verify-code`,
1275
+ `Tasks: ${tasks.map(t => t.name).join(', ')}`,
1276
+ ...results.map(r => `- ${r.name}: exit=${r.exit} (${r.durMs}ms) — \`${r.cmd}\``),
1277
+ `Tail:`,
1278
+ '```',
1279
+ results.map(r => `[${r.name}]\n${r.tail}`).join('\n---\n').slice(0, 1500),
1280
+ '```'
1281
+ ].join('\n');
1282
+ append(evidencePath(root), evBlock + '\n');
1283
+ ok(`evidence 기록: .harness/review-evidence.md`);
1284
+ if (failedCnt) { process.exitCode = 1; warn(`${failedCnt}개 task 실패 — progress의 해당 row를 incomplete로 표시하세요.`); }
1285
+ }
1286
+
1287
+ // ===== 1.9.7 B: lessons — 과거 결정/실수 자동 회수 =====
1288
+ function lessonsCmd(root) {
1289
+ root = absRoot(root);
1290
+ const query = arg('--query', null);
1291
+ const limit = parseInt(arg('--limit', '10'), 10);
1292
+ const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1293
+ const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1294
+ const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
1295
+ const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
1296
+ const lessons = [];
1297
+ // decisions: ### 블록 전체
1298
+ for (const block of decisions.split(/\n(?=### )/)) {
1299
+ if (!block.startsWith('### ')) continue;
1300
+ const m = block.match(/^### (.+)$/m);
1301
+ if (!m) continue;
1302
+ lessons.push({ source: 'decisions.md', title: m[1].trim(), block });
1303
+ }
1304
+ // evidence: ## 블록 중 실패/롤백/버그 표지가 있는 것
1305
+ for (const block of evidence.split(/\n(?=## )/)) {
1306
+ if (!block.startsWith('## ')) continue;
1307
+ if (/✗|\bfail(ed)?\b|롤백|재발|incomplete|\bbug\b|버그|warning/i.test(block)) {
1308
+ const m = block.match(/^## (.+)$/m);
1309
+ if (m) lessons.push({ source: 'review-evidence.md', title: m[1].trim(), block });
1310
+ }
1311
+ }
1312
+ // task-log: 실패 키워드 라인
1313
+ for (const line of tlog.split('\n')) {
1314
+ if (line.length > 4 && /✗|\bfail|롤백|재발|incomplete|버그/i.test(line)) {
1315
+ lessons.push({ source: 'task-log.md', title: line.replace(/^[-*]\s*/, '').slice(0, 80), block: line });
1316
+ }
1317
+ }
1318
+ // handoff: 미완료/블로커 항목
1319
+ if (handoff) {
1320
+ const incompleteSec = handoff.match(/## Incomplete[\s\S]*?(?=\n## |$)/);
1321
+ if (incompleteSec && incompleteSec[0].split('\n').slice(1).some(l => /^- (?!없음)/.test(l))) {
1322
+ lessons.push({ source: 'session-handoff.md', title: 'Incomplete / Blocked from last session', block: incompleteSec[0] });
1323
+ }
1324
+ }
1325
+ let filtered = lessons;
1326
+ if (query) {
1327
+ const q = new RegExp(escapeRegex(query), 'i');
1328
+ filtered = lessons.filter(l => q.test(l.title) || q.test(l.block));
1329
+ }
1330
+ log(`# Lessons${query ? ` — query="${query}"` : ''}`);
1331
+ if (!filtered.length) {
1332
+ if (query) ok(`"${query}" 관련 과거 lessons 없음`);
1333
+ else ok('과거 lessons 없음 (decisions/evidence가 비어있거나 실패 표지 없음)');
1334
+ return;
1335
+ }
1336
+ log(`총 ${filtered.length}건 발견:`);
1337
+ for (const l of filtered.slice(0, limit)) {
1338
+ log(`\n[${l.source}] ${l.title}`);
1339
+ const preview = l.block.replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').slice(0, 240);
1340
+ log(` ${preview}${l.block.length > 240 ? '…' : ''}`);
1341
+ }
1342
+ if (filtered.length > limit) log(`\n💡 ${filtered.length - limit}개 더 있음 — --limit ${filtered.length}`);
1343
+ }
1344
+
1140
1345
  // ===== 1.9.3: Causal / reuse / consistency =====
1141
1346
  const CODE_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.css','.scss','.sass','.less','.html','.htm','.vue','.svelte','.md','.json','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php']);
1142
1347
  function* walkCode(root, base = root, depth = 0, extras = null) {
@@ -1363,6 +1568,13 @@ function guideCmd(root, target) {
1363
1568
  log('');
1364
1569
  log('## 3. UI consistency — 디자인 토큰 일치');
1365
1570
  uiConsistency(root);
1571
+ log('');
1572
+ log('## 4. Lessons — 과거 결정/실수 회수 (1.9.7)');
1573
+ if (q) {
1574
+ // lessonsCmd가 arg('--query')를 읽으므로 임시로 push
1575
+ if (!process.argv.includes('--query')) { process.argv.push('--query', q); }
1576
+ lessonsCmd(root);
1577
+ } else log('(target/--query 없음 — lessons 검색 스킵)');
1366
1578
  log('\n💡 다음 단계: 위 결과를 바탕으로 작업 계획을 plan/progress에 기록 후 진행하세요.');
1367
1579
  }
1368
1580
 
@@ -1539,7 +1751,10 @@ function viewworkInstall(root) {
1539
1751
  }
1540
1752
 
1541
1753
  function help() {
1542
- 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 [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\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`);
1754
+ 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
+ leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
1756
+ 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`);
1543
1758
  }
1544
1759
 
1545
1760
  async function main() {
@@ -1578,6 +1793,8 @@ async function main() {
1578
1793
  if (cmd === 'skill' && args[1] === 'remove') return skillRemove(absRoot(arg('--path', process.cwd())), args[2]);
1579
1794
  if (cmd === 'skill' && args[1] === 'consolidate') return skillConsolidate(absRoot(arg('--path', process.cwd())));
1580
1795
  if (cmd === 'gate') return gate(args[1] || process.cwd());
1796
+ if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
1797
+ if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
1581
1798
  if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
1582
1799
  if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
1583
1800
  if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
@@ -1603,6 +1820,7 @@ async function main() {
1603
1820
  if (sub==='update') return taskUpdate(root, args[2]);
1604
1821
  if (sub==='drop') return taskDrop(root, args[2]);
1605
1822
  if (sub==='fix-evidence') return taskFixEvidence(root);
1823
+ if (sub==='relink') return taskRelink(root);
1606
1824
  }
1607
1825
  return help();
1608
1826
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.5",
3
+ "version": "1.9.7",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -235,6 +235,135 @@ total++;
235
235
  if (!(strongOK && weakHint)) failed++;
236
236
  }
237
237
 
238
+ // 1.9.7 A: verify-code — 가짜 package.json + 통과 시나리오
239
+ total++;
240
+ {
241
+ const tmpV = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-vc-'));
242
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpV, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
243
+ fs.writeFileSync(path.join(tmpV, 'package.json'), JSON.stringify({ name: 't', version: '0.0.1', scripts: { test: 'node -e "console.log(\\"OK\\");process.exit(0)"' } }));
244
+ const r = cp.spawnSync(process.execPath, [CLI, 'verify-code', tmpV], { encoding: 'utf8' });
245
+ const ev = fs.readFileSync(path.join(tmpV, '.harness/review-evidence.md'), 'utf8');
246
+ const ok = r.status === 0 && /test passed/.test(r.stdout) && /verify-code \(자동\)/.test(ev);
247
+ console.log(ok ? '✓ B(1.9.7-A) verify-code: 통과 + evidence 자동 기록' : `✗ A 실패\n${r.stdout}`);
248
+ if (!ok) failed++;
249
+ }
250
+
251
+ // 1.9.7 A: verify-code — 실패 시나리오
252
+ total++;
253
+ {
254
+ const tmpV2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-vc2-'));
255
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpV2, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
256
+ fs.writeFileSync(path.join(tmpV2, 'package.json'), JSON.stringify({ name: 't', version: '0.0.1', scripts: { test: 'node -e "process.exit(2)"' } }));
257
+ const r = cp.spawnSync(process.execPath, [CLI, 'verify-code', tmpV2], { encoding: 'utf8' });
258
+ const ok = r.status === 1 && /test failed/.test(r.stdout);
259
+ console.log(ok ? '✓ B(1.9.7-A) verify-code: 실패 시 exit=1' : `✗ A2 실패 status=${r.status}`);
260
+ if (!ok) failed++;
261
+ }
262
+
263
+ // 1.9.7 B: lessons — decisions/evidence에 시드 후 query로 회수
264
+ total++;
265
+ {
266
+ fs.appendFileSync(path.join(tmp, '.harness/decisions.md'), `\n### 2026-05-08 — Decision: 캐시 차등 TTL 도입\n- Reason: 단일 5분 TTL이 daily 데이터에 비효율\n- Impact: open-meteo 응답 캐시 적중률 ↑\n`);
267
+ fs.appendFileSync(path.join(tmp, '.harness/review-evidence.md'), `\n## 2026-05-08 e2e\n✗ 캐시 키 불안정 — 좌표 정규화 부재 (롤백 후 fix)\n`);
268
+ const r = cp.spawnSync(process.execPath, [CLI, 'lessons', '--query', '캐시', '--path', tmp], { encoding: 'utf8' });
269
+ const ok = r.status === 0 && /Lessons.*query="캐시"/.test(r.stdout) && /decisions\.md/.test(r.stdout) && /review-evidence\.md/.test(r.stdout);
270
+ console.log(ok ? '✓ B(1.9.7-B) lessons: decisions+evidence 회수' : `✗ B 실패\n${r.stdout}`);
271
+ if (!ok) failed++;
272
+ }
273
+
274
+ // 1.9.7 B: guide가 lessons를 자동 통합
275
+ total++;
276
+ {
277
+ const r = cp.spawnSync(process.execPath, [CLI, 'guide', 'src/components/Card.html', '--path', tmp], { encoding: 'utf8' });
278
+ const ok = r.status === 0 && /## 4\. Lessons/.test(r.stdout);
279
+ console.log(ok ? '✓ B(1.9.7-B) guide: lessons 섹션 자동 추가' : '✗ B guide 통합 실패');
280
+ if (!ok) failed++;
281
+ }
282
+
283
+ // 1.9.7 C: lazy detect --auto-track
284
+ total++;
285
+ {
286
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-c-'));
287
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
288
+ // 의도된 진짜 TODO (주석)
289
+ fs.mkdirSync(path.join(tmpC, 'src'), { recursive: true });
290
+ fs.writeFileSync(path.join(tmpC, 'src/a.js'), `// TODO: 추적해야 할 미완료 작업\nfunction foo() {}\n`);
291
+ // review-evidence에 npm test 키워드 추가 (lazy detect의 다른 신호 우회)
292
+ fs.appendFileSync(path.join(tmpC, '.harness/review-evidence.md'), '\n## seed\nCommand: npm test\n');
293
+ // session close로 handoff 채우기
294
+ cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpC], { stdio: 'ignore' });
295
+ // --auto-track 실행
296
+ const r = cp.spawnSync(process.execPath, [CLI, 'lazy', 'detect', tmpC, '--auto-track'], { encoding: 'utf8' });
297
+ const tracker = fs.readFileSync(path.join(tmpC, '.harness/progress-tracker.md'), 'utf8');
298
+ const known = fs.existsSync(path.join(tmpC, '.harness/known-todos.json')) ? JSON.parse(fs.readFileSync(path.join(tmpC, '.harness/known-todos.json'), 'utf8')) : [];
299
+ const ok = /TODO src\/a\.js:1/.test(tracker) && /auto-tracked/.test(tracker) && known.length === 1;
300
+ console.log(ok ? '✓ B(1.9.7-C) lazy detect --auto-track: 자동 등록 + known-todos.json' : `✗ C 실패\nTracker:\n${tracker.split('\n').filter(l=>l.startsWith('| T-')).slice(-3).join('\n')}\nKnown: ${JSON.stringify(known)}`);
301
+ if (!ok) failed++;
302
+ }
303
+
304
+ // 1.9.7 C: 같은 TODO 재실행 시 known-todos가 적용되어 newTodos 0
305
+ total++;
306
+ {
307
+ // 위 시나리오 이어서 — known-todos가 있으므로 새 TODO=0이어야 함
308
+ // 별도 새 tmp로 재현 (tmpC는 위에서 자동 등록됐으니 same dir에서 다시 호출)
309
+ // 위 tmpC는 already auto-tracked
310
+ const tmpC2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-c2-'));
311
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC2, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
312
+ fs.mkdirSync(path.join(tmpC2, 'src'), { recursive: true });
313
+ fs.writeFileSync(path.join(tmpC2, 'src/a.js'), `// TODO: 추적된 항목\n`);
314
+ // known-todos.json에 미리 등록
315
+ fs.writeFileSync(path.join(tmpC2, '.harness/known-todos.json'), JSON.stringify([{ file: 'src/a.js', line: 1, text: '// TODO: 추적된 항목', ackAt: '2026-05-08T00:00:00Z' }]));
316
+ fs.appendFileSync(path.join(tmpC2, '.harness/review-evidence.md'), '\n## seed\nCommand: npm test\n');
317
+ cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpC2], { stdio: 'ignore' });
318
+ const r = cp.spawnSync(process.execPath, [CLI, 'lazy', 'detect', tmpC2], { encoding: 'utf8' });
319
+ // newTodos가 0이므로 "new: 0" 또는 TODO 카운트가 1이지만 progress 추적에 자동 등록 안 됨
320
+ // 핵심: TODO 1개 잡혀도 known이라 새 항목 노출 X
321
+ const ok = /new: 0\b/.test(r.stdout) || !/💡 자동 등록/.test(r.stdout);
322
+ console.log(ok ? '✓ B(1.9.7-C) known-todos: 재카운트 회피' : `✗ C2 실패\n${r.stdout}`);
323
+ if (!ok) failed++;
324
+ }
325
+
326
+ // 1.9.6 회귀: task relink — 인위적 link 손실 → 자동 매칭 제안 + --apply 자동 복구
327
+ total++;
328
+ {
329
+ // plan.md에 새 milestone 추가, progress엔 link 없는 비슷한 row 추가
330
+ const planPath_ = path.join(tmp, '.harness/plan.md');
331
+ fs.appendFileSync(planPath_,
332
+ `\n### M-9001. 캐시 헬퍼 모듈\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] 캐시 helper\n` +
333
+ `\n### M-9002. 인증 헬퍼 모듈\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] 인증 helper\n`);
334
+ // progress에 link 없는 비슷한 row 2개 추가
335
+ const trackerPath_ = path.join(tmp, '.harness/progress-tracker.md');
336
+ fs.appendFileSync(trackerPath_,
337
+ `| T-9001 | done | 캐시 helper 구현 | tests:5/5 (link lost) | 다음 단계 | 2026-05-08 |\n` +
338
+ `| T-9002 | done | 인증 helper 구현 | tests:8/8 (link lost) | 다음 단계 | 2026-05-08 |\n`);
339
+
340
+ // 제안 모드
341
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'task', 'relink', '--path', tmp], { encoding: 'utf8' });
342
+ const okSuggest = r1.status === 0 && /M-9001/.test(r1.stdout) && /M-9002/.test(r1.stdout) && /최선 후보/.test(r1.stdout);
343
+ console.log(okSuggest ? '✓ B(1.9.6) task relink 제안: 2개 매칭 발견' : `✗ B(1.9.6) 제안 실패\n${r1.stdout}`);
344
+ if (!okSuggest) failed++;
345
+ }
346
+ total++;
347
+ {
348
+ // --apply
349
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'task', 'relink', '--apply', '--path', tmp], { encoding: 'utf8' });
350
+ const tracker = fs.readFileSync(path.join(tmp, '.harness/progress-tracker.md'), 'utf8');
351
+ const okApply = r2.status === 0 && /M-9001/.test(tracker) && /M-9002/.test(tracker);
352
+ console.log(okApply ? '✓ B(1.9.6) task relink --apply: 자동 복구' : '✗ B(1.9.6) --apply 실패');
353
+ if (!okApply) failed++;
354
+ }
355
+ total++;
356
+ {
357
+ // audit이 task relink 안내를 출력하는지 (이번엔 link 복구 후라 미연결 milestone 없을 것)
358
+ // 일부러 새 milestone 추가 후 audit
359
+ fs.appendFileSync(path.join(tmp, '.harness/plan.md'),
360
+ `\n### M-9999. 매칭 후보 없는 milestone\nStatus: planned\n\nTasks:\n- [ ] x\n`);
361
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'audit', tmp], { encoding: 'utf8' });
362
+ const ok = /milestones without progress entry/.test(r3.stdout) && /M-9999/.test(r3.stdout) && /leerness task relink/.test(r3.stdout);
363
+ console.log(ok ? '✓ B(1.9.6) audit이 task relink 안내 출력' : `✗ B(1.9.6) audit 안내 누락\n${r3.stdout}`);
364
+ if (!ok) failed++;
365
+ }
366
+
238
367
  // 1.9.5 G 회귀: impact medium 카테고리 — 동적 path 패턴
239
368
  total++;
240
369
  {