leerness 1.9.423 → 1.9.425

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/leerness.js CHANGED
@@ -31,7 +31,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
31
31
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
32
32
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 (MERGE_OVERWRITE_FILES/MINIMAL_SKIP_KEYS 포함)
33
33
 
34
- const VERSION = '1.9.423';
34
+ const VERSION = '1.9.425';
35
35
 
36
36
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
37
37
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -2929,7 +2929,7 @@ function _selfTestCases() {
2929
2929
  { name: 'MCP notification 준수: id없는 요청 무응답 가드 + ping {} (UR-0049 설치리뷰 1.9.313)', run: () => { const src = read(__filename); const guard = src.includes("const isNotification = !('id' in req)") && src.includes("req.method.startsWith('notifications/')") && src.includes('if (isNotification) return;'); const ping = src.includes("req.method === 'ping'") && /ping[\s\S]{0,140}result: \{\} \}/.test(src); return guard && ping; } },
2930
2930
  { name: 'PowerShell 감지: pwsh7(channel/Documents\\PowerShell/install) + ps5.1 영구경로 과경고 안함 (UR-0052 설치리뷰 1.9.314)', run: () => { const f = _detectPwshFromEnv; const pwsh7a = f({ POWERSHELL_DISTRIBUTION_CHANNEL: 'MSI:Windows 10' }).version === '7'; const pwsh7b = f({ PSModulePath: 'C:\\Users\\me\\Documents\\PowerShell\\Modules' }).version === '7'; const pwsh7c = f({ PSModulePath: 'C:\\Program Files\\PowerShell\\7\\Modules' }).version === '7'; const noFalsePs5 = f({ PSModulePath: 'C:\\Users\\me\\Documents\\WindowsPowerShell\\Modules' }).isPowerShell === false; const cmdSys = f({ PSModulePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\Modules' }).isPowerShell === false; const empty = f({}).isPowerShell === false; const src = read(__filename); const wired = src.includes('const fromEnv = _detectPwshFromEnv()') && src.includes('const pwshEnv = _detectPwshFromEnv()'); return pwsh7a && pwsh7b && pwsh7c && noFalsePs5 && cmdSys && empty && wired; } },
2931
2931
  { name: 'doc/surface 정합: doctor 명령 + stale MCP 카운트 동적화(commands/banner) (UR-0054 설치리뷰 1.9.315)', run: () => { const src = read(__filename); const doctorOk = typeof doctorCmd === 'function' && /cmd === 'doctor'/.test(src) && /# leerness doctor/.test(src); const dynCount = /MCP 도구: \$\{_mcpToolCount\(\)\}/.test(src) && /외부 AI 통합 \(MCP \$\{_mcpToolCount\(\)\} 도구\)/.test(src); return doctorOk && dynCount; } },
2932
- { name: 'drift 마커 버그: session-handoff 프론트매터는 ^--- 일 때만 + drift 최신 Last generated (1.9.316)', run: () => { const src = read(__filename); const writeFix = src.includes('if (/^---\\r?\\n/.test(cur))') && src.includes('writeUtf8(handoffPath(root), frontmatter + block)'); const readFix = src.includes('matchAll(/Last generated') && src.includes('allGen[allGen.length - 1]'); return writeFix && readFix; } },
2932
+ { name: 'drift 마커 버그: session-handoff 프론트매터는 ^--- 일 때만 + drift 최신 Last generated (1.9.316)', run: () => { const src = read(__filename); const scSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'session-close.js')); const writeFix = scSrc.includes('if (/^---\\r?\\n/.test(cur))') && scSrc.includes('writeUtf8(handoffPath(root), frontmatter + block)'); const readFix = src.includes('matchAll(/Last generated') && src.includes('allGen[allGen.length - 1]'); return writeFix && readFix; } },
2933
2933
  { name: '텔레메트리 분리: 내부 auto-call(LEERNESS_INTERNAL) usage 집계 제외 + 주요 spawn 마킹 (UR-0051 설치리뷰 1.9.317)', run: () => { const src = read(__filename); const guard = src.includes("process.env.LEERNESS_INTERNAL !== '1'"); const marked = (src.match(/LEERNESS_INTERNAL: '1'/g) || []).length >= 10; const reviewMarked = /'review-request'[\s\S]{0,200}LEERNESS_INTERNAL: '1'/.test(src); return guard && marked && reviewMarked; } },
2934
2934
  { name: 'lib/pure-utils: HTML 파싱 유틸 3종 모듈 분리 + 동작 + 인라인 제거 (UR-0025 1.9.318)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._htmlToText === 'function' && typeof m._extractTitle === 'function' && typeof m._extractLinks === 'function'; const work = m._htmlToText('<p>Hello <b>World</b></p>') === 'Hello World' && m._extractTitle('<html><title>My &amp; Page</title></html>') === 'My & Page' && m._extractLinks('<a href="/a">A</a><a href="https://other.com/b">B</a>', 'https://x.com/').length === 1; const moved = m._htmlToText === _htmlToText && !/^function _htmlToText\(html\) \{/m.test(read(__filename)); return fnOk && work && moved; } },
2935
2935
  { name: 'MCP ToolRegistry 일치성: 모든 도구 def 가 dispatch case 보유 + 고아 case 0 + requiredTier 완비 (UR-0044 1.9.319)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const missing = tools.filter(t => !src.includes("case '" + t.name + "':")); const cases = [...src.matchAll(/case '(leerness_[a-z_]+)':/g)].map(m => m[1]); const defNames = new Set(tools.map(t => t.name)); const orphans = [...new Set(cases)].filter(c => !defNames.has(c)); const tierOk = tools.every(t => typeof t.requiredTier === 'string' && PERMISSION_TIERS.includes(t.requiredTier)); return tools.length >= 83 && missing.length === 0 && orphans.length === 0 && tierOk; } },
@@ -2965,7 +2965,7 @@ function _selfTestCases() {
2965
2965
  { name: 'UR-0061(외부리뷰 P1): roadmap CSS 값 살균 — :root/</style> breakout 차단', run: () => { const m = require('../lib/pure-utils'); const css = m._roadmapTokenStyles({ 'color.primary': 'red;}' + '</style><script>alert(1)</script>' }, {}); const blocked = !css.includes('<') && !css.includes('>'); const primaryLine = (css.split('\n').find(l => l.includes('--lr-primary')) || ''); const noBreakout = !primaryLine.replace(/;$/, '').includes('}'); const preserved = m._roadmapTokenStyles({ 'color.primary': '#2563eb' }, {}).includes('--lr-primary: #2563eb'); return blocked && noBreakout && preserved; } },
2966
2966
  { name: 'UR-0060(외부리뷰 P1): SECRET_PATTERNS 20종 (unquoted 보강) — GitLab/JWT/DB-URI/SendGrid/AWS-secret/Bearer 보강 + 오탐 가드', run: () => { const c = require('../lib/catalogs'); const hit = s => c.SECRET_PATTERNS.some(p => { p.re.lastIndex = 0; return p.re.test(s); }); const det = hit('glpat-' + 'x'.repeat(20)) && hit('eyJ' + 'x'.repeat(15) + '.eyJ' + 'y'.repeat(15) + '.' + 'z'.repeat(15)) && hit('postgres://u:p@host:5432/db') && hit('SG.' + 'x'.repeat(22) + '.' + 'y'.repeat(43)) && hit('aws_secret_access_key = "' + 'x'.repeat(40) + '"') && hit('Bearer ' + 'x'.repeat(25)); const clean = !hit('const u = "john' + '_doe_2024";') && !hit('https://example.com/path/to/page'); return c.SECRET_PATTERNS.length === 20 && det && clean; } },
2967
2967
  { name: 'UR-0068(외부리뷰 P2): _roadmapParseMilestones 블록 경계 — 다음 milestone status 누출 차단', run: () => { const m = require('../lib/pure-utils'); const r = m._roadmapParseMilestones('### M-0001. A\n\n### M-0002. B\nStatus: done\nProgress: 80%\n'); return r.length === 2 && r[0].status === 'planned' && r[0].progress === 0 && r[1].status === 'done' && r[1].progress === 80; } },
2968
- { name: 'UR-0066(외부리뷰 P2): shell:true 주입 가드 — agents bench task _shellQuoteArg + fetchNpmLatest cmd.exe args', run: () => { const m = require('../lib/pure-utils'); const src = read(__filename); const benchQuoted = src.includes('const qTask = ' + '_shellQuoteArg(task)'); const npmSafe = /'\/d', '\/s', '\/c', 'npm', 'view'/.test(src); const q = m._shellQuoteArg('a & b'); const safe = (process.platform === 'win32' ? q === '"a & b"' : q === "'a & b'"); return benchQuoted && npmSafe && safe; } },
2968
+ { name: 'UR-0066(외부리뷰 P2): shell:true 주입 가드 — agents bench task _shellQuoteArg + fetchNpmLatest cmd.exe args', run: () => { const m = require('../lib/pure-utils'); const src = read(__filename); const agentsSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'agents.js')); const benchQuoted = agentsSrc.includes('const qTask = ' + '_shellQuoteArg(task)'); const npmSafe = /'\/d', '\/s', '\/c', 'npm', 'view'/.test(src); const q = m._shellQuoteArg('a & b'); const safe = (process.platform === 'win32' ? q === '"a & b"' : q === "'a & b'"); return benchQuoted && npmSafe && safe; } },
2969
2969
  { name: 'UR-0072(외부리뷰 P3): compareVer pre-release + _classifyCJK 한자 kana 귀속', run: () => { const m = require('../lib/pure-utils'); const verOk = m.compareVer('1.9.0-beta', '1.9.0') === -1 && m.compareVer('1.9.0', '1.9.0-beta') === 1 && m.compareVer('1.9.5', '1.9.5') === 0 && m.compareVer('1.9.6', '1.9.5') === 1; const jp = Buffer.from([0xE3, 0x81, 0x82, 0xE6, 0x97, 0xA5, 0xE6, 0x9C, 0xAC]); const cn = Buffer.from([0xE4, 0xB8, 0xAD, 0xE5, 0x9B, 0xBD]); const rj = m._classifyCJK(jp, jp.length); const rc = m._classifyCJK(cn, cn.length); const cjkOk = rj.japanese > rj.chinese && rc.chinese > 0 && rc.japanese === 0; return verOk && cjkOk; } },
2970
2970
  { name: 'UR-0075 Phase A: 마이그레이션 가이드(_migrationGuideText) + migrate --guide 와이어 + init/migrate/update --path', run: () => { const m = require('../lib/pure-utils'); const g = m._migrationGuideText('1.9.355'); const guideOk = typeof g === 'string' && g.includes('마이그레이션 가이드') && g.includes('update --check --path') && g.includes('selftest') && g.includes('canonical JSON') && g.includes('롤백') && g.includes('1.9.355'); const src = read(__filename); const wired = src.includes("has('--guide') || args[1] === " + "'guide'") && src.includes('install(arg(' + "'--path', args[1] || process.cwd())") && src.includes('updateCmd(arg(' + "'--path', args[1] || process.cwd())"); return guideOk && wired; } },
2971
2971
  { name: 'UR-0075 Phase B: migrate audit(dry-run 스키마 drift) 명령 + 와이어', run: () => { const src = read(__filename); return typeof migrateAuditCmd === 'function' && src.includes('migrateAuditCmd(arg(' + "'--path'") && src.includes("args[1] === " + "'audit'"); } },
@@ -3073,6 +3073,27 @@ function _selfTestCases() {
3073
3073
  } },
3074
3074
  { name: '9라운드 (UR-0119/0120): team review(메인 검수) — _composeTeamPlan reviewStep + handoff 검수필요 + team add 와이어 (1.9.414)', run: () => { const m = require('../lib/pure-utils'); const on = m._composeTeamPlan({ id: 't', members: ['a', 'b'], personas: ['security'] }, '점검'); const off = m._composeTeamPlan({ id: 't', members: ['a'], review: false }, '점검'); const planOk = on.review === true && !!on.reviewStep && on.reviewStep.suggestedCommand.includes('verify-claim') && off.review === false && !off.reviewStep; const rem = m._teamHandoffReminders([{ id: 'r', schedule: 'every-session', status: 'active', members: ['a'], review: true }]); const remOk = rem.length === 1 && rem[0].includes('검수필요'); const teamSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'team.js')); const wired = teamSrc.includes("review: !has('--no-review')") && teamSrc.includes('메인 검수 (필수)'); return planOk && remOk && wired; } },
3075
3075
  { name: '파일명 변경 (UR-0126): bin 파일=leerness.js + package.json bin/main 일치 (1.9.419)', run: () => { const okName = path.basename(__filename) === 'leerness.js'; let pkg; try { pkg = require('../package.json'); } catch { return false; } const okBin = pkg && pkg.bin && pkg.bin.leerness === 'bin/leerness.js' && pkg.main === 'bin/leerness.js'; return okName && okBin; } },
3076
+ { name: 'UR-0025 큰핸들러 모듈화 10번째: sessionClose → lib/session-close.js + DI 위임 (1.9.425)', run: () => {
3077
+ const m = require('../lib/session-close');
3078
+ const expOk = typeof m.sessionClose === 'function';
3079
+ const src = read(__filename);
3080
+ const delegated = src.includes("require('../lib/session-close')") && src.includes('_sessionClose.sessionClose(root, opts,');
3081
+ const modSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'session-close.js'));
3082
+ const bodyMarker = 'recommended' + 'Direction'; // session-close 본문 고유(split-literal 자기참조 회피)
3083
+ const movedToLib = modSrc.includes("require('./io')") && modSrc.includes("require('./pure-utils')") && modSrc.includes('STATUSES, MARK') && modSrc.includes(bodyMarker) && !src.includes(bodyMarker);
3084
+ return expOk && delegated && movedToLib;
3085
+ } },
3086
+ { name: 'UR-0025 큰핸들러 모듈화 9번째: agentsCmd → lib/agents.js + DI 위임 + rest→array (1.9.424)', run: () => {
3087
+ const m = require('../lib/agents');
3088
+ const expOk = typeof m.agentsCmd === 'function';
3089
+ const src = read(__filename);
3090
+ const delegated = src.includes("require('../lib/agents')") && src.includes('_agents.agentsCmd(root, sub, args,');
3091
+ const modSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'agents.js'));
3092
+ const bodyMarker = 'LEERNESS_NO_' + 'MULTIAGENT_LESSON'; // agents 본문 고유(split-literal 자기참조 회피)
3093
+ const sigTransform = modSrc.includes('function agentsCmd(root, sub, args = [], deps = {})') && modSrc.includes("agentsCmd(root, 'list', [], deps)");
3094
+ const movedToLib = modSrc.includes("require('./io')") && modSrc.includes("require('./agent-registry')") && sigTransform && modSrc.includes(bodyMarker) && !src.includes(bodyMarker);
3095
+ return expOk && delegated && movedToLib;
3096
+ } },
3076
3097
  { name: 'UR-0025 큰핸들러 모듈화 8번째: healthCmd → lib/health.js + DI 위임 + 동작 (1.9.423)', run: () => {
3077
3098
  const m = require('../lib/health');
3078
3099
  const expOk = typeof m.healthCmd === 'function';
@@ -11068,448 +11089,9 @@ function _dispatchCommand(agentId, task, writeMode, model) {
11068
11089
  return `# ${agentId}: 명령 빌더 미정의`;
11069
11090
  }
11070
11091
 
11071
- function agentsCmd(root, sub, ...args) {
11072
- root = absRoot(root || process.cwd());
11073
- // .env 자동 로드 (1.9.22)
11074
- _loadEnvFile(root);
11075
- _loadEnvFile(path.join(root, '..'));
11076
-
11077
- if (!sub || sub === 'list') {
11078
- // 1.9.157: Provider Registry 통합 — 빌트인 5종 + 사용자 정의 provider 포함
11079
- const providers = _allProviders(root);
11080
- const userIds = new Set(_readUserProviders(root).map(u => u.id));
11081
- const checks = providers.map(a => ({ ...(_checkAgent(a)), source: userIds.has(a.id) ? 'user' : 'builtin' }));
11082
- if (has('--json')) { log(JSON.stringify({ agents: checks }, null, 2)); return; }
11083
- log(`# 외부 AI CLI 오케스트레이션 (1.9.30)`);
11084
- log('');
11085
- log(`| Agent | source | env (${'env=1 활성'}) | 설치 | 버전 | 상태 |`);
11086
- log(`|---|---|---|---|---|---|`);
11087
- for (const c of checks) {
11088
- const envMark = c.enabled ? '✓' : '✗';
11089
- const instMark = c.installed ? '✓' : '✗';
11090
- const statusEmoji = c.status === 'ready' ? '🟢 ready' : c.status === 'not-installed' ? '⚪ 미설치' : c.status === 'disabled' ? '🟡 비활성' : '❓';
11091
- log(`| ${c.id} | ${c.source} | ${envMark} ${c.envFlag} | ${instMark} | ${c.version || '-'} | ${statusEmoji} |`);
11092
- }
11093
- const ready = checks.filter(c => c.status === 'ready');
11094
- log('');
11095
- log(`## 활성 (${ready.length}/${checks.length}): ${ready.map(c => c.id).join(', ') || '(없음)'}`);
11096
- if (!ready.length) {
11097
- log('');
11098
- log(`💡 활성화 방법:`);
11099
- log(` 1) CLI 설치 (예: \`npm i -g @openai/codex-cli\`, \`npm i -g @google/antigravity-cli\`)`);
11100
- log(` 2) .env 또는 환경변수: LEERNESS_ENABLE_CODEX=1, LEERNESS_ENABLE_AGY=1`);
11101
- log(` 3) \`leerness agents check\`로 재확인`);
11102
- log(` 💡 1.9.157: 빌트인 외 CLI 추가: \`leerness provider add <id> --bin <cmd>\``);
11103
- } else {
11104
- log('');
11105
- log(`💡 메인 에이전트가 sub-agent 분배 시 위 ${ready.length}개 CLI 활용 가능:`);
11106
- log(` \`leerness agents dispatch "<task>" --to <id>\` 로 프롬프트 전달`);
11107
- }
11108
- return;
11109
- }
11110
-
11111
- if (sub === 'check') {
11112
- // list의 alias, 단 명시적 재확인 (JSON 출력 기본)
11113
- // 1.9.157: Provider Registry 통합
11114
- const providers = _allProviders(root);
11115
- const userIds = new Set(_readUserProviders(root).map(u => u.id));
11116
- const checks = providers.map(a => ({ ...(_checkAgent(a)), source: userIds.has(a.id) ? 'user' : 'builtin' }));
11117
- if (has('--json')) { log(JSON.stringify({ agents: checks, ready: checks.filter(c => c.status === 'ready').map(c => c.id) }, null, 2)); return; }
11118
- return agentsCmd(root, 'list'); // 비-JSON은 list와 동일
11119
- }
11120
-
11121
- // 1.9.152: agents multi — 1.9.151 install 복수 선택된 ready 에이전트들에 일괄 dispatch 명령 생성
11122
- // 단일 task → 활성 N개 에이전트 동시 dispatch 명령들. 사용자가 한 번에 복사 실행하거나 메인 에이전트가 spawn.
11123
- if (sub === 'multi') {
11124
- const task = args.filter(x => !x.startsWith('-')).join(' ').trim() || arg('--task', null);
11125
- if (!task) { fail('multi "<task>" 또는 --task 필요'); return process.exit(1); }
11126
- const onlyArg = arg('--only', null); // 'claude,codex' 처럼 콤마 구분 — 활성 중에서 추가 필터
11127
- const writeMode = has('--write');
11128
- const execute = has('--execute'); // 1.9.156: 명령 출력 → 실제 spawn + consensus 합의
11129
- const checks = EXTERNAL_AGENTS.map(a => ({ def: a, status: _checkAgent(a) }));
11130
- let ready = checks.filter(x => x.status.status === 'ready');
11131
- if (onlyArg) {
11132
- const wanted = new Set(onlyArg.split(/[,\s]+/).filter(Boolean));
11133
- ready = ready.filter(x => wanted.has(x.def.id));
11134
- }
11135
- if (!ready.length) {
11136
- fail('활성 (ready) 에이전트 없음 — `leerness agents list` 로 확인. 1.9.151 install 흐름에서 복수 선택 후 .env 활성화 필요.');
11137
- return process.exit(1);
11138
- }
11139
- // 1.9.281 (UR-0034): 권한 등급 게이트 — enforce ON 시 shell-write 초과 차단 (기본 OFF, 동작 불변)
11140
- if (execute) {
11141
- const pol = _policyEnforce(root, 'agents multi --execute');
11142
- if (!pol.allowed) { fail(pol.reason); return process.exit(1); }
11143
- if (pol.advisory) warn(`정책 advisory: 'agents multi --execute' 요구 등급 ${pol.required} > 허용 ${pol.allowedTier} (enforce OFF — 진행). leerness policy 로 등급 확인`);
11144
- }
11145
- // 1.9.156: --execute 모드 — 실제 spawn + 결과 수집 + multi-signal consensus
11146
- if (execute) {
11147
- return (async () => {
11148
- const timeout = parseInt(arg('--timeout', '60'), 10) * 1000;
11149
- if (!has('--json')) {
11150
- log(`# leerness agents multi --execute (1.9.156) — ${ready.length}개 활성 에이전트 병렬 호출`);
11151
- log(`task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
11152
- log(`mode: ${writeMode ? '✏ write' : '🔒 read-only'} · timeout=${timeout / 1000}s`);
11153
- log(`대상: ${ready.map(x => x.def.id).join(', ')}`);
11154
- log('');
11155
- log('## 병렬 호출 중...');
11156
- }
11157
- const t0 = Date.now();
11158
- // 병렬 _cliChat 호출 (sandbox 자동: runCommandSafe + env scrub + observability)
11159
- const results = await Promise.all(ready.map(async ({ def }) => {
11160
- const start = Date.now();
11161
- const r = await _cliChat(root, def.id, task, { timeout });
11162
- return {
11163
- agent: def.id,
11164
- elapsed: Date.now() - start,
11165
- ok: r.ok,
11166
- response: r.response || '',
11167
- error: r.error || null,
11168
- responseTokens: Math.ceil((r.response || '').length / 4) // 대략 token 추정
11169
- };
11170
- }));
11171
- const totalElapsed = Date.now() - t0;
11172
- const ok = results.filter(r => r.ok);
11173
- const failures = results.filter(r => !r.ok);
11174
- _recordRun(root, { kind: 'agents_multi_execute', count: ready.length, success: ok.length, durationMs: totalElapsed, task: task.slice(0, 200) });
11175
- // 1.9.155 consensus 로직 재사용 — multi-signal scoring (tokens + overlap + lengthFit)
11176
- let best = null, scored = [];
11177
- if (ok.length) {
11178
- const tokenizer = (s) => new Set(String(s || '').toLowerCase().match(/[\w가-힣]{3,}/g) || []);
11179
- const wordsOf = ok.map(o => tokenizer(o.response));
11180
- const maxTokens = Math.max(...ok.map(o => o.responseTokens), 1);
11181
- const avgLen = ok.reduce((s, o) => s + o.response.length, 0) / ok.length;
11182
- const stdLen = Math.sqrt(ok.reduce((s, o) => s + (o.response.length - avgLen) ** 2, 0) / ok.length) || 1;
11183
- scored = ok.map((o, i) => {
11184
- const tokensNorm = o.responseTokens / maxTokens;
11185
- const myWords = wordsOf[i];
11186
- let overlapSum = 0;
11187
- for (let j = 0; j < wordsOf.length; j++) {
11188
- if (i === j) continue;
11189
- let inter = 0;
11190
- for (const w of myWords) if (wordsOf[j].has(w)) inter++;
11191
- overlapSum += inter / Math.max(myWords.size, 1);
11192
- }
11193
- const overlap = (ok.length > 1) ? overlapSum / (ok.length - 1) : 0;
11194
- const z = Math.abs((o.response.length - avgLen) / stdLen);
11195
- const lengthFit = z <= 1.5 ? (1 - z / 1.5) : 0;
11196
- const score = 0.4 * tokensNorm + 0.4 * overlap + 0.2 * lengthFit;
11197
- return { ...o, score, tokensNorm, overlap, lengthFit };
11198
- }).sort((a, b) => b.score - a.score);
11199
- best = scored[0];
11200
- }
11201
- if (has('--json')) {
11202
- log(JSON.stringify({
11203
- task, count: ready.length, success: ok.length, totalElapsedMs: totalElapsed,
11204
- results: scored.length ? scored : results,
11205
- best: best ? { agent: best.agent, score: best.score, response: best.response } : null,
11206
- failures
11207
- }, null, 2));
11208
- return;
11209
- }
11210
- log(`\n## 결과: ${ok.length}/${ready.length} 성공 · 총 ${totalElapsed}ms (병렬)`);
11211
- for (const r of results) {
11212
- if (r.ok) log(` ✓ ${r.agent.padEnd(8)} · ${r.elapsed}ms · ${r.responseTokens} 토큰`);
11213
- else log(` ✗ ${r.agent.padEnd(8)} · ${r.elapsed}ms · ${(r.error || '').slice(0, 60)}`);
11214
- }
11215
- if (best) {
11216
- log('');
11217
- log(`## 🏆 합의 선택 (multi-signal consensus, 1.9.155)`);
11218
- log(` best: ${best.agent} · score=${best.score.toFixed(3)} (tokens=${best.tokensNorm.toFixed(2)} · overlap=${best.overlap.toFixed(2)} · lengthFit=${best.lengthFit.toFixed(2)})`);
11219
- if (scored.length > 1) {
11220
- log(` others: ${scored.slice(1, 4).map(s => `${s.agent}=${s.score.toFixed(2)}`).join(', ')}`);
11221
- }
11222
- log(` --- 처음 600자 ---`);
11223
- log(best.response.slice(0, 600));
11224
- // task-log 기록
11225
- try {
11226
- const tlp = taskLogPath(root);
11227
- const block = `\n## ${today()} agents multi --execute (1.9.156)\n- task: ${task.slice(0, 200)}\n- agents: ${ready.map(x => x.def.id).join(', ')}\n- success: ${ok.length}/${ready.length}\n- best: ${best.agent} (score=${best.score.toFixed(3)})\n`;
11228
- append(tlp, block);
11229
- } catch {}
11230
- // 1.9.193: B축 (멀티 Sub-Agent 오케스트라) 보강 — consensus 결과를 lessons.md 에 자동 기록
11231
- // 같은 task 재시도 시 과거 best agent + score 가 handoff lessons auto-recall 에서 매칭
11232
- // 끄기: LEERNESS_NO_MULTIAGENT_LESSON=1
11233
- if (process.env.LEERNESS_NO_MULTIAGENT_LESSON !== '1') {
11234
- try {
11235
- const lp = lessonsPath(root);
11236
- const lessonBlock = `\n### ${today()} multi-agent consensus — best=${best.agent} (1.9.193)\n`
11237
- + `- task: ${task.slice(0, 200)}\n`
11238
- + `- agents: ${ready.map(x => x.def.id).join(', ')} (${ok.length}/${ready.length} success)\n`
11239
- + `- best agent: ${best.agent}, score=${best.score.toFixed(3)}\n`
11240
- + (scored.length > 1 ? `- others: ${scored.slice(1, 4).map(s => `${s.agent}=${s.score.toFixed(2)}`).join(', ')}\n` : '')
11241
- + `- lesson: 같은 keyword 재발 시 ${best.agent} 우선 시도 (multi-signal consensus 입증)\n`;
11242
- append(lp, lessonBlock);
11243
- } catch {}
11244
- }
11245
- }
11246
- if (failures.length && !best) {
11247
- process.exitCode = 1;
11248
- }
11249
- })();
11250
- }
11251
- if (has('--json')) {
11252
- log(JSON.stringify({
11253
- task, count: ready.length,
11254
- agents: ready.map(x => ({ id: x.def.id, version: x.status.version })),
11255
- commands: ready.map(x => _dispatchCommand(x.def.id, task, writeMode)),
11256
- // 1.9.266 (UR-0021 2단계): 각 에이전트 슬래시 명령 힌트 — sub-agent 가 알맞은 슬래시 사용
11257
- slashCommands: ready.reduce((acc, x) => { const h = _agentSlashHint(root, x.def.id); if (h) acc[x.def.id] = { invoke: h.invoke, commands: h.commands.map(c => c.cmd) }; return acc; }, {})
11258
- }, null, 2));
11259
- return;
11260
- }
11261
- log(`# leerness agents multi (1.9.152) — ${ready.length}개 활성 에이전트 일괄 dispatch`);
11262
- log(`task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
11263
- log(`mode: ${writeMode ? '✏ write (파일 수정 가능)' : '🔒 read-only (분석 전용, 안전)'}`);
11264
- log(`대상: ${ready.map(x => x.def.id).join(', ')}`);
11265
- log('');
11266
- log('## 각 에이전트 실행 명령 (사용자가 병렬 실행 또는 메인 에이전트가 spawn)');
11267
- log('');
11268
- for (const { def, status } of ready) {
11269
- log(`### [${def.id}] (v${status.version || '?'})`);
11270
- log('```sh');
11271
- log(_dispatchCommand(def.id, task, writeMode));
11272
- log('```');
11273
- // 1.9.266 (UR-0021 2단계): 에이전트별 슬래시 명령 힌트
11274
- try {
11275
- const hint = _agentSlashHint(root, def.id);
11276
- if (hint && hint.commands.length) log(` 🤖 슬래시: ${hint.commands.slice(0, 8).map(c => c.cmd).join(' ')}${hint.invoke === 'subcommand' ? ' (하위명령)' : ''}`);
11277
- } catch {}
11278
- log('');
11279
- }
11280
- log('## 정책 (1.9.152 / 1.9.156)');
11281
- log(` - 기본 모드: 명령 문자열만 출력 (사용자/메인 에이전트가 명시적으로 실행)`);
11282
- log(` - 1.9.156 신규: \`--execute\` 플래그 시 leerness가 직접 ${ready.length}개 sub-agent 병렬 spawn + multi-signal consensus 자동 합의`);
11283
- log(` 예: leerness agents multi "<task>" --execute (또는 --execute --json)`);
11284
- log(` - 활성 에이전트 변경: \`.env\`에서 LEERNESS_ENABLE_<CLI>=1/0 또는 \`leerness setup-agents\` 재실행`);
11285
- log(` - quota 체크: \`leerness agents quota\``);
11286
- return;
11287
- }
11288
- if (sub === 'dispatch') {
11289
- const task = args.filter(x => !x.startsWith('-')).join(' ').trim() || arg('--task', null);
11290
- let target = arg('--to', null);
11291
- if (!task) { fail('dispatch "<task>" 또는 --task 필요'); return process.exit(1); }
11292
- // 1.9.152: --multi 또는 --to=all 또는 --to 없음 + 활성 ≥2 → multi 모드로 routing
11293
- if (has('--multi') || target === 'all' || target === '*') {
11294
- return agentsCmd(root, 'multi', ...args);
11295
- }
11296
- // 1.9.270: --role <role> — 설정된 역할 → provider+model 라우팅 (--to 없을 때)
11297
- const roleArg = arg('--role', null);
11298
- let roleModel = arg('--model', null);
11299
- let rolePersona = '';
11300
- if (roleArg && !target) {
11301
- const resolved = _resolveRole(root, roleArg);
11302
- if (!resolved) { fail(`역할 미설정: ${_normalizeRole(roleArg)} — leerness roles set ${_normalizeRole(roleArg)} --provider <id> 또는 roles suggest --apply`); return process.exit(1); }
11303
- target = resolved.provider;
11304
- if (!roleModel) roleModel = resolved.model;
11305
- rolePersona = resolved.persona || '';
11306
- log(`🎭 역할 ${_normalizeRole(roleArg)} → ${target}${roleModel ? ' / ' + roleModel : ''}`);
11307
- if (rolePersona) log(` persona: ${rolePersona}`);
11308
- }
11309
- if (!target) { fail('--to <agent_id> 또는 --role <role> 필요 (claude/codex/agy/grok/copilot) — 활성 전체 일괄은 `leerness agents multi`'); return process.exit(1); }
11310
- const agentDef = EXTERNAL_AGENTS.find(a => a.id === target);
11311
- if (!agentDef) { fail(`알 수 없는 agent: ${target}`); return process.exit(1); }
11312
- // 1.9.36: 작업 유형 키워드 분석 → 최적 CLI 추천 (ready 체크 전에 출력 — 비활성이어도 추천)
11313
- const recommendation = _recommendAgent(task);
11314
- const recommended = recommendation.target;
11315
- if (recommended && recommended !== target) {
11316
- log(`💡 추천: 이 작업은 ${recommended}가 더 적합 (${recommendation.reason})`);
11317
- }
11318
- const status = _checkAgent(agentDef);
11319
- if (status.status !== 'ready') {
11320
- fail(`${target} 비활성 (${status.status}). 환경변수 ${agentDef.envFlag}=1 + CLI 설치 필요.`);
11321
- return process.exit(1);
11322
- }
11323
- // 1.9.36: --write 시 파일 수정 가능 권장 플래그 자동 첨부, 미명시 시 read-only 안전 모드
11324
- const writeMode = has('--write');
11325
- const readOnly = has('--readonly') || !writeMode;
11326
- // 실제 호출은 안 함 — 프롬프트만 생성 (사용자가 명시적으로 실행)
11327
- log(`# leerness agents dispatch (1.9.36)`);
11328
- log(`대상: ${target} (${agentDef.bin})`);
11329
- log(`상태: 🟢 ready, 버전 ${status.version || '?'}`);
11330
- log(`모드: ${writeMode ? '✏ write (파일 수정 가능)' : '🔒 read-only (분석 전용, 안전)'}`);
11331
- log('');
11332
- log(`## 실행 명령 (사용자가 복사해서 실행)`);
11333
- if (roleModel) log(`# 🎭 모델: ${roleModel} (역할 기반 라우팅, 1.9.270)`);
11334
- log('');
11335
- // 1.9.270: _dispatchCommand 로 통일 (roleModel 주입) — 명령 빌더 단일화
11336
- log(_dispatchCommand(target, task, writeMode, roleModel));
11337
- if (target === 'claude' && writeMode) log(`# ⚠ --dangerously-skip-permissions: 도구 권한 자동 승인 (파일 수정 가능)`);
11338
- if (target === 'codex') { log(`# ℹ codex는 PowerShell 경유 — POSIX /tmp 경로는 C:\\tmp\\로 해석됨`); if (writeMode) log(`# ⚠ --dangerously-bypass-approvals-and-sandbox: sandbox 우회`); }
11339
- if (target === 'agy' && writeMode) log(`# ⚠ --yolo: 워크스페이스 파일 직접 수정 가능`);
11340
- if (target === 'grok' && writeMode) log(`# ⚠ grok --yolo: 자동 승인 (배포판에 따라 플래그 상이 가능)`);
11341
- // 1.9.266 (UR-0021 2단계): 대상 에이전트의 슬래시 명령 힌트 — sub-agent 작업 시 알맞은 슬래시 명령 참조
11342
- try {
11343
- const hint = _agentSlashHint(root, target);
11344
- if (hint && hint.commands.length) {
11345
- log('');
11346
- log(`## 🤖 ${target} 슬래시 명령 (1.9.265, UR-0021)`);
11347
- if (hint.invoke === 'subcommand') log(` ※ 슬래시가 아닌 하위명령: ${hint.commands.map(c => c.cmd).join(' / ')}`);
11348
- else log(` 세션 내 사용 가능: ${hint.commands.slice(0, 10).map(c => c.cmd).join(' ')}`);
11349
- log(` → 전체/기록: leerness slash-commands ${target} [--record]`);
11350
- }
11351
- } catch {}
11352
- log('');
11353
- log(`## 정책 (1.9.36)`);
11354
- log(` - leerness는 외부 CLI를 자동 호출하지 않음 (사용자 명시적 실행)`);
11355
- log(` - 메인 에이전트(Claude)가 위 명령을 보고 sub-agent로 spawn 가능`);
11356
- log(` - quota 체크: \`leerness agents quota\` (1.9.31+)`);
11357
- log(` - 동시 호출 시: \`leerness agents bench "<task>"\` (1.9.36)`);
11358
- log('');
11359
- log(`## 분배 시 안전 규칙 (1.9.35)`);
11360
- log(` - sub-agent 프롬프트에 "당신만 수정할 파일 경로"를 명시 (파일 경로 격리)`);
11361
- log(` - sub-agent에 "보고 시 \`stat <file>\` 또는 mtime 확인 결과 첨부" 요구 (자기 격리 검증)`);
11362
- log(` - 사양 사전 정의 (예: TICK_SPEC.md) → \`leerness contract verify\`로 사후 검증`);
11363
- log(` - 같은 파일 동시 쓰기는 last-writer-wins 위험 (1.9.34 검증)`);
11364
- return;
11365
- }
11366
-
11367
- if (sub === 'bench') {
11368
- // 1.9.36: 같은 prompt를 ready CLI 모두에 동시 호출 + 시간/응답 길이/exit code 비교
11369
- const task = args.filter(x => !x.startsWith('-')).join(' ').trim() || arg('--task', null);
11370
- if (!task) { fail('bench "<task>" 필요'); return process.exit(1); }
11371
- const timeoutS = parseInt(arg('--timeout', '60'), 10);
11372
- const writeMode = has('--write');
11373
- const ready = EXTERNAL_AGENTS.map(a => ({ agent: a, status: _checkAgent(a) }))
11374
- .filter(x => x.status.status === 'ready');
11375
- if (!ready.length) {
11376
- fail('ready CLI 없음 — leerness setup-agents 또는 .env에 LEERNESS_ENABLE_X=1 설정 필요');
11377
- return process.exit(1);
11378
- }
11379
- log(`# leerness agents bench (1.9.36)`);
11380
- log(`task: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}`);
11381
- log(`참여 CLI: ${ready.map(r => r.agent.id).join(', ')} (${ready.length}개)`);
11382
- log(`타임아웃: ${timeoutS}s/CLI · 모드: ${writeMode ? 'write' : 'read-only'}`);
11383
- log('');
11384
- log('병렬 호출 중... (병렬 fork 후 wait)');
11385
- log('');
11386
- const results = [];
11387
- const promises = ready.map(({ agent, status }) => new Promise((resolve) => {
11388
- const t0 = Date.now();
11389
- let cmd, cmdArgs;
11390
- // 1.9.352 (UR-0066 외부리뷰): shell:true 경로에 raw task 전달 시 셸 메타문자(& | $() 백틱) 주입 위험 → _shellQuoteArg 로 단일 토큰화 (안전 경로 _cliChat 와 일관)
11391
- const qTask = _shellQuoteArg(task);
11392
- if (agent.id === 'claude') {
11393
- cmdArgs = writeMode ? ['--print', '--dangerously-skip-permissions', qTask] : ['--print', qTask];
11394
- cmd = 'claude';
11395
- } else if (agent.id === 'codex') {
11396
- cmdArgs = writeMode
11397
- ? ['exec', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', qTask]
11398
- : ['exec', '--skip-git-repo-check', qTask];
11399
- cmd = 'codex';
11400
- } else if (agent.id === 'agy') {
11401
- cmdArgs = writeMode ? ['-p', qTask, '--yolo'] : ['-p', qTask];
11402
- cmd = 'agy';
11403
- } else if (agent.id === 'copilot') {
11404
- cmdArgs = ['copilot', 'suggest', qTask];
11405
- cmd = 'gh';
11406
- }
11407
- const r = cp.spawn(cmd, cmdArgs, { shell: true });
11408
- let stdout = '', stderr = '';
11409
- r.stdout.on('data', d => { stdout += d; });
11410
- r.stderr.on('data', d => { stderr += d; });
11411
- const timer = setTimeout(() => { r.kill(); }, timeoutS * 1000);
11412
- r.on('close', (code) => {
11413
- clearTimeout(timer);
11414
- const elapsed = Date.now() - t0;
11415
- results.push({
11416
- id: agent.id, exit: code, elapsed,
11417
- stdout: stdout.trim().split('\n').slice(-3).join('\n'),
11418
- stderrLen: stderr.length,
11419
- ok: code === 0 && stdout.trim().length > 0
11420
- });
11421
- resolve();
11422
- });
11423
- r.on('error', (err) => {
11424
- clearTimeout(timer);
11425
- results.push({ id: agent.id, exit: -1, elapsed: Date.now() - t0, stdout: '', stderrLen: 0, error: err.message, ok: false });
11426
- resolve();
11427
- });
11428
- }));
11429
- return Promise.all(promises).then(() => {
11430
- if (has('--json')) { log(JSON.stringify({ task, results }, null, 2)); return; }
11431
- log(`| CLI | 시간 | exit | 응답 길이 | 마지막 라인 |`);
11432
- log(`|---|---:|---:|---:|---|`);
11433
- // sort by elapsed
11434
- results.sort((a, b) => a.elapsed - b.elapsed);
11435
- for (const r of results) {
11436
- const respLen = (r.stdout || '').length;
11437
- const last = (r.stdout || '').split('\n').pop().slice(0, 50);
11438
- log(`| ${r.id} | ${r.elapsed}ms | ${r.exit} | ${respLen} | ${last.replace(/\|/g, '\\|')} |`);
11439
- }
11440
- log('');
11441
- const okCount = results.filter(r => r.ok).length;
11442
- log(`결과: ${okCount}/${results.length} 성공`);
11443
- const fastest = results.filter(r => r.ok).sort((a, b) => a.elapsed - b.elapsed)[0];
11444
- if (fastest) log(`🏆 가장 빠름: ${fastest.id} (${fastest.elapsed}ms)`);
11445
- });
11446
- }
11447
-
11448
- if (sub === 'quota') {
11449
- // 1.9.31: 각 CLI 사용량/쿼터 추정 + provider 대시보드 링크
11450
- const results = [];
11451
- for (const agent of EXTERNAL_AGENTS) {
11452
- const base = _checkAgent(agent);
11453
- const out = { id: agent.id, bin: agent.bin, status: base.status, quota: null, hint: null, raw: null };
11454
- if (base.status !== 'ready') {
11455
- out.hint = base.status === 'not-installed' ? `${agent.bin} CLI 미설치` : base.status === 'disabled' ? `${agent.envFlag}=1 필요` : '알 수 없음';
11456
- results.push(out); continue;
11457
- }
11458
- // CLI별 quota 탐지 시도
11459
- try {
11460
- if (agent.id === 'claude') {
11461
- // claude는 /status 슬래시 (대화형)만 지원. 비대화형 추정 불가.
11462
- out.quota = 'unknown';
11463
- out.hint = '대화 내 `/status` 슬래시 또는 https://console.anthropic.com/settings/usage 확인';
11464
- } else if (agent.id === 'codex') {
11465
- // codex CLI: codex --help에 usage 명령 있는지 확인
11466
- const r = cp.spawnSync(agent.bin, ['--help'], { encoding: 'utf8', timeout: 4000, shell: true });
11467
- const help = (r.stdout || r.stderr || '').toLowerCase();
11468
- if (help.includes('usage') || help.includes('quota')) {
11469
- out.quota = 'cli-supported';
11470
- out.hint = '`codex usage` 또는 `codex quota` 시도 가능';
11471
- } else {
11472
- out.quota = 'unknown';
11473
- out.hint = 'https://platform.openai.com/account/usage 확인';
11474
- }
11475
- out.raw = help.slice(0, 200);
11476
- } else if (agent.id === 'agy') {
11477
- // agy CLI (Antigravity): 무료 티어는 분당 60req 제한, CLI 자체에선 노출 안 됨
11478
- out.quota = 'rate-limited';
11479
- out.hint = '무료 티어: 60 req/min, 1000 req/day · Antigravity 유료 플랜은 https://antigravity.google.com';
11480
- } else if (agent.id === 'copilot') {
11481
- // gh copilot은 GitHub Copilot 구독 (월 단위 quota 없음, individual/business 플랜)
11482
- const r = cp.spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 4000, shell: true });
11483
- const authed = r.status === 0;
11484
- out.quota = authed ? 'subscription' : 'not-authed';
11485
- out.hint = authed ? 'Copilot 구독자 무제한 (월 플랜) · https://github.com/settings/copilot' : '`gh auth login` 필요';
11486
- }
11487
- } catch (e) {
11488
- out.quota = 'error';
11489
- out.hint = e.message;
11490
- }
11491
- results.push(out);
11492
- }
11493
- if (has('--json')) { log(JSON.stringify({ quota: results }, null, 2)); return; }
11494
- log(`# 외부 AI CLI quota 추정 (1.9.31)`);
11495
- log('');
11496
- log(`| Agent | 상태 | quota | 안내 |`);
11497
- log(`|---|---|---|---|`);
11498
- for (const q of results) {
11499
- const statusEmoji = q.status === 'ready' ? '🟢' : q.status === 'not-installed' ? '⚪' : q.status === 'disabled' ? '🟡' : '❓';
11500
- log(`| ${q.id} | ${statusEmoji} ${q.status} | ${q.quota || '-'} | ${q.hint || '-'} |`);
11501
- }
11502
- log('');
11503
- log(`## 주의`);
11504
- log(` - leerness는 CLI 사용량을 직접 추적하지 않음 (provider 대시보드 참조)`);
11505
- log(` - rate-limit/quota는 plan/티어에 따라 달라짐`);
11506
- log(` - sub-agent 분배 시 quota 여유 큰 CLI 우선 활용 권장`);
11507
- return;
11508
- }
11509
-
11510
- fail('사용법: leerness agents list|check|quota|dispatch|bench [--write] "<task>" [--to <id>]');
11511
- return process.exit(1);
11512
- }
11092
+ const _agents = require('../lib/agents');
11093
+ // 1.9.424 (UR-0025/UR-0125 핸들러 모듈화 9번째): agentsCmd → lib/agents.js (DI 위임, rest→array)
11094
+ function agentsCmd(root, sub, ...args) { return _agents.agentsCmd(root, sub, args, { VERSION, has, arg, _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, _shellQuoteArg, lessonsPath, taskLogPath }); }
11513
11095
 
11514
11096
  function personaCmd(root, sub, idOrName, ...rest) {
11515
11097
  root = absRoot(root || process.cwd());
@@ -11671,605 +11253,9 @@ function llmBenchRecordCmd(root) {
11671
11253
  ok(`기록됨: ${histFile}`);
11672
11254
  }
11673
11255
 
11674
- function sessionClose(root, opts = {}) {
11675
- root = absRoot(root);
11676
- // 1.9.103: --json 모드 stdout 억제 구조화 출력
11677
- const jsonMode = !!opts.json || has('--json');
11678
- const _origWrite = process.stdout.write.bind(process.stdout);
11679
- if (jsonMode) process.stdout.write = () => true;
11680
- const jsonResult = { version: VERSION, root, closedAt: now() };
11681
- try {
11682
- const rows = readProgressRows(root);
11683
- const buckets = {};
11684
- for (const s of STATUSES) buckets[s] = [];
11685
- for (const r of rows) (buckets[r.status] || (buckets[r.status] = [])).push(r);
11686
- // 1.9.103: JSON 결과 누적
11687
- jsonResult.taskCounts = {};
11688
- for (const s of STATUSES) jsonResult.taskCounts[s] = (buckets[s] || []).length;
11689
- jsonResult.recommendedDirection = (buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || null;
11690
- jsonResult.nextExactStep = (buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || null;
11691
-
11692
- function rowsToList(arr) {
11693
- if (!arr || !arr.length) return '- 없음';
11694
- return arr.map(r => `- ${r.id} ${r.request} → next: ${r.nextAction}`).join('\n');
11695
- }
11696
-
11697
- // 1.9.287 (Codex 리뷰 수렴): evidence 임베딩 시 코드펜스(```) 가 session-handoff.md 마크다운을 깨뜨리는 품질 버그 수정.
11698
- const evidenceSummary = _sanitizeFences(exists(evidencePath(root)) ? (read(evidencePath(root)).split('\n').slice(-30).join('\n')) : '(no review-evidence.md)');
11699
- const block = [
11700
- `# Session Handoff`,
11701
- ``,
11702
- `Last generated: ${now()}`,
11703
- ``,
11704
- `## Completed`,
11705
- rowsToList(buckets['done']),
11706
- ``,
11707
- `## In Progress`,
11708
- rowsToList(buckets['in-progress']),
11709
- ``,
11710
- `## Incomplete / Waiting / On Hold / Blocked`,
11711
- rowsToList([...(buckets['incomplete']||[]), ...(buckets['waiting']||[]), ...(buckets['on-hold']||[]), ...(buckets['blocked']||[])]),
11712
- ``,
11713
- `## Dropped`,
11714
- rowsToList(buckets['dropped']),
11715
- ``,
11716
- `## Verification`,
11717
- '```',
11718
- evidenceSummary.trim() || '(empty)',
11719
- '```',
11720
- ``,
11721
- `## Recommended Direction`,
11722
- `- ${(buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '다음 우선순위를 사용자와 정합니다.'}`,
11723
- ``,
11724
- `## Next Exact Step`,
11725
- `- ${(buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || '없음'}`,
11726
- ``
11727
- ].join('\n');
11728
- const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
11729
- // 1.9.316 (drift 마커 버그): 프론트매터는 파일이 '---' 로 시작할 때만 추출.
11730
- // 이전: 본문의 '---'(수평선/구분자)을 프론트매터 종료로 오인 → 구 블록(구 'Last generated')을 보존 →
11731
- // session-handoff.md 에 'Last generated' 중복 누적 → drift 가 첫(=구) 매치를 읽어 'session close 누락' 영구 오발화.
11732
- let frontmatter = '';
11733
- if (/^---\r?\n/.test(cur)) {
11734
- const fmEnd = cur.indexOf('\n---\n', 4);
11735
- if (fmEnd > 0) frontmatter = cur.slice(0, fmEnd + 5) + MARK + '\n';
11736
- }
11737
- writeUtf8(handoffPath(root), frontmatter + block);
11738
-
11739
- if (exists(currentStatePath(root))) {
11740
- let cs = read(currentStatePath(root));
11741
- cs = cs.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
11742
- cs = cs.replace(/## Now\n[\s\S]*?(?=\n## Next)/, `## Now\n- ${(buckets['in-progress'][0]?.request) || '대기 중'}\n`);
11743
- cs = cs.replace(/## Next\n[\s\S]*?(?=\n## Blockers)/, `## Next\n- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}\n`);
11744
- cs = cs.replace(/## Blockers\n[\s\S]*$/, `## Blockers\n${(buckets['blocked']||[]).map(b=>`- ${b.id} ${b.request}`).join('\n') || '-'}\n`);
11745
- writeUtf8(currentStatePath(root), cs);
11746
- }
11747
-
11748
- append(taskLogPath(root), `\n## ${today()} session-close\n- Generated session-handoff.md and refreshed current-state.md.\n`);
11749
-
11750
- log('# Session Close');
11751
- log('## Task Lists');
11752
- for (const s of STATUSES) {
11753
- log(`\n### ${s}`);
11754
- log(rowsToList(buckets[s]));
11755
- }
11756
- // 1.9.8: 룰 검증 자동 수행 + 보고
11757
- const ruleResults = verifyRules(root);
11758
- jsonResult.rules = ruleResults.map(r => ({ id: r.id, trigger: r.trigger, verified: r.verified, note: r.note }));
11759
- log('\n## ⚡ User Rules verification');
11760
- if (!ruleResults.length) log('- 활성 룰 없음');
11761
- else {
11762
- log('| ID | Trigger | Rule | Verified | Note |');
11763
- log('|---|---|---|---|---|');
11764
- const ic = { pass: '✓ pass', pending: '⓿ pending', manual: 'ⓘ manual', baseline: '○ baseline' };
11765
- for (const r of ruleResults) log(`| ${r.id} | ${r.trigger} | ${r.rule.slice(0, 40)} | ${ic[r.verified] || '?'} | ${r.note} |`);
11766
- }
11767
- log('\n## Required final response sections');
11768
- log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과');
11769
- ok(`session-handoff.md and current-state.md updated`);
11770
- // 1.9.12: session close 끝에 roadmap.html 자동 갱신
11771
- _autoRoadmap(root, 'session-close');
11772
- // 1.9.57: --suggest 옵션 — 마감 시 skill suggest + drift check + lessons 통합 보고
11773
- // 1.9.59: default 활성 — --no-suggest로 명시 비활성 가능
11774
- const suggestEnabled = (has('--suggest') || (!has('--no-suggest') && process.env.LEERNESS_NO_SUGGEST !== '1'));
11775
- if (suggestEnabled) {
11776
- const isTty = process.stdout && process.stdout.isTTY;
11777
- const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
11778
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
11779
- log('');
11780
- log(cy('## 💡 다음 라운드 추천 (1.9.57 --suggest)'));
11781
- // 1) skill suggest
11782
- try {
11783
- const r = cp.spawnSync(process.execPath, [__filename, 'skill', 'suggest', '--path', root, '--min', '3', '--json'],
11784
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
11785
- const j = JSON.parse(r.stdout);
11786
- if (j.candidates && j.candidates.length) {
11787
- log(dim(' 📌 신규 skill 후보 (Hermes-style 자동 학습):'));
11788
- for (const c of j.candidates.slice(0, 3)) log(` • ${c.keyword} (${c.count}회 등장, 출처: ${c.source})`);
11789
- jsonResult.skillCandidates = j.candidates.slice(0, 5);
11790
- }
11791
- } catch {}
11792
- // 2) drift check
11793
- try {
11794
- const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
11795
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
11796
- const j = JSON.parse(r.stdout.trim());
11797
- if (j.level) {
11798
- log(dim(` 🩺 drift 상태: ${j.level} ${j.score}/200`));
11799
- if (j.fired && j.fired.length) log(dim(` 🔥 ${j.fired.length}건 임계 초과 — \`leerness drift check\` 상세`));
11800
- jsonResult.drift = { level: j.level, score: j.score, fired: (j.fired || []).map(f => ({ label: f.label, weight: f.weight })) };
11801
- }
11802
- } catch {}
11803
- // 3) usage stats top
11804
- try {
11805
- const stats = _readUsageStats(root);
11806
- const entries = Object.entries(stats.commands || {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
11807
- if (entries.length) {
11808
- log(dim(` 📊 가장 많이 쓴 명령: ${entries.map(([c, n]) => `${c}(${n})`).join(', ')}`));
11809
- jsonResult.topCommands = entries.map(([command, count]) => ({ command, count }));
11810
- }
11811
- // 1.9.74: MCP tools/call 통계 + rare 도구 노출
11812
- if (stats.mcp && stats.mcp.tools) {
11813
- const mcpEntries = Object.entries(stats.mcp.tools).sort((a, b) => b[1] - a[1]);
11814
- if (mcpEntries.length) {
11815
- const mcpTotal = mcpEntries.reduce((s, [, n]) => s + n, 0);
11816
- log(dim(` 🔌 MCP 호출 (1.9.74): 총 ${mcpTotal}회, top: ${mcpEntries.slice(0, 3).map(([t, n]) => `${t}(${n})`).join(', ')}`));
11817
- const threshold = Math.max(1, Math.floor(mcpTotal * 0.05));
11818
- const rare = mcpEntries.filter(([, n]) => n <= threshold).map(([t]) => t);
11819
- if (rare.length && mcpTotal >= 5) log(dim(` 💡 드물게 호출된 MCP: ${rare.slice(0, 4).join(', ')}`));
11820
- jsonResult.mcpStats = { total: mcpTotal, top: mcpEntries.slice(0, 5).map(([tool, count]) => ({ tool, count })), rare: rare.slice(0, 10) };
11821
- }
11822
- }
11823
- } catch {}
11824
- // 1.9.74: skill match query top (skill-suggestions.md 누적)
11825
- try {
11826
- const histPath = path.join(root, '.harness', 'skill-suggestions.md');
11827
- if (exists(histPath)) {
11828
- const histTxt = read(histPath);
11829
- const queries = [];
11830
- for (const block of histTxt.split(/\n(?=## )/)) {
11831
- const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
11832
- if (h) queries.push(h[2]);
11833
- }
11834
- if (queries.length) {
11835
- // 같은 query 개수 카운트
11836
- const counts = {};
11837
- for (const q of queries) counts[q] = (counts[q] || 0) + 1;
11838
- const topQueries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3);
11839
- log(dim(` 📒 skill match query 누적 (1.9.74): 총 ${queries.length}회 / 종류 ${Object.keys(counts).length}개`));
11840
- for (const [q, n] of topQueries) log(dim(` • "${q.slice(0, 50)}"${n > 1 ? ` (${n}회)` : ''}`));
11841
- }
11842
- }
11843
- } catch {}
11844
- log('');
11845
- }
11846
- // 1.9.13: 세션 카운터 + 자동 한 줄 요약 + 5세션마다 깊은 회고
11847
- try {
11848
- const sc = readSessionCounter(root);
11849
- sc.count = (sc.count || 0) + 1;
11850
- sc.lastCloseAt = now();
11851
- writeSessionCounter(root, sc);
11852
- const agg = _retroAggregate(root);
11853
- log(`\n## 📈 진행 요약 (session #${sc.count})`);
11854
- log(` ${_retroOneLine(agg)}`);
11855
- // 1.9.132: archive 활동 1줄 요약 — 마감 시점에 DELETE 활동 가시화 (handoff 7번째 회수와 symmetric)
11856
- try {
11857
- const hdSC = path.join(root, '.harness');
11858
- const arc = { d: 0, l: 0, p: 0, total: 0 };
11859
- for (const [k, f] of [['d', 'decisions.archive.md'], ['l', 'lessons.archive.md'], ['p', 'plan.archive.md']]) {
11860
- const fp = path.join(hdSC, f);
11861
- if (exists(fp)) {
11862
- const entries = _parseArchiveBlocks(read(fp));
11863
- arc[k] = entries.length;
11864
- arc.total += entries.length;
11865
- }
11866
- }
11867
- if (arc.total > 0) {
11868
- log(` 🗑 archive 누적: D${arc.d}/L${arc.l}/P${arc.p} (${arc.total}건) — 복원 후보: leerness memory archive list`);
11869
- }
11870
- } catch {}
11871
- if (sc.count % 5 === 0) {
11872
- log(`\n## 🔄 ${sc.count}세션 마일스톤 — 자동 회고 (5세션마다)`);
11873
- retroCmd(root);
11874
- sc.lastDeepRetroAt = now();
11875
- writeSessionCounter(root, sc);
11876
- } else {
11877
- const left = 5 - (sc.count % 5);
11878
- log(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`);
11879
- }
11880
- // 1.9.16: 워크스페이스 안내 (다른 leerness 프로젝트가 있으면)
11881
- try {
11882
- const wsCands = [path.resolve(root, '_apps'), path.resolve(root, '..', '_apps')];
11883
- let wsCount = 0;
11884
- for (const base of wsCands) {
11885
- if (!exists(base)) continue;
11886
- try { if (!fs.statSync(base).isDirectory()) continue; } catch { continue; }
11887
- for (const e of fs.readdirSync(base)) {
11888
- try {
11889
- const p = path.join(base, e);
11890
- if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness')) && p !== root) wsCount++;
11891
- } catch {}
11892
- }
11893
- }
11894
- if (wsCount > 0) log(` 🌐 워크스페이스에 ${wsCount}개 다른 leerness 프로젝트 — \`leerness retro --all-apps\`로 통합 회고`);
11895
- jsonResult.workspacePeers = wsCount;
11896
- } catch {}
11897
- } catch (e) {
11898
- warn('retro 요약 실패: ' + (e && e.message ? e.message : e));
11899
- jsonResult.retroSummaryError = e && e.message ? e.message : String(e);
11900
- }
11901
- } finally {
11902
- // 1.9.103: stdout 복원
11903
- if (jsonMode) process.stdout.write = _origWrite;
11904
- }
11905
- // 1.9.103: JSON 모드 — 구조화 출력
11906
- if (jsonMode) {
11907
- try {
11908
- const sc = readSessionCounter(root);
11909
- jsonResult.sessionNumber = sc.count;
11910
- } catch {}
11911
- // 1.9.122: memorySurface 통합 (handoff --json 1.9.115 와 동일 패턴)
11912
- try {
11913
- const rows0 = readProgressRows(root);
11914
- const tasksByStatus0 = {};
11915
- for (const s of STATUSES) tasksByStatus0[s] = 0;
11916
- for (const r of rows0) tasksByStatus0[r.status] = (tasksByStatus0[r.status] || 0) + 1;
11917
- const tasksInProgress0 = tasksByStatus0['in-progress'] || 0;
11918
- const decisionsCount0 = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
11919
- const rules0 = readRules(root);
11920
- const rulesActive0 = rules0.filter(r => r.status === 'active').length;
11921
- const planText0 = exists(planPath(root)) ? read(planPath(root)) : '';
11922
- const milestones0 = (planText0.match(/^### M-\d{4}\./gm) || []).length;
11923
- const lessonsCount0 = _loadLessons(root).length;
11924
- // 1.9.130: archive 카운트 통합
11925
- const archiveCountsS = { decisions: 0, lessons: 0, plan: 0, total: 0 };
11926
- try {
11927
- const hdS = path.join(root, '.harness');
11928
- for (const [key, file] of [['decisions', 'decisions.archive.md'], ['lessons', 'lessons.archive.md'], ['plan', 'plan.archive.md']]) {
11929
- const fpS = path.join(hdS, file);
11930
- if (exists(fpS)) {
11931
- const entries = _parseArchiveBlocks(read(fpS));
11932
- archiveCountsS[key] = entries.length;
11933
- archiveCountsS.total += entries.length;
11934
- }
11935
- }
11936
- } catch {}
11937
- jsonResult.memorySurface = {
11938
- tasks: { inProgress: tasksInProgress0, total: rows0.length, byStatus: tasksByStatus0 },
11939
- decisions: { count: decisionsCount0 },
11940
- rules: { active: rulesActive0, total: rules0.length },
11941
- plan: { milestones: milestones0 },
11942
- lessons: { count: lessonsCount0 },
11943
- archive: archiveCountsS, // 1.9.130
11944
- summary: `T${tasksInProgress0}/D${decisionsCount0}/R${rulesActive0}/P${milestones0}/L${lessonsCount0}`,
11945
- };
11946
- // 1.9.142: featureCounts 통합 — session close JSON에 Feature Graph 통계
11947
- try {
11948
- const { nodes: fNodesC } = _readFeatureGraph(root);
11949
- const edgeCount = fNodesC.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
11950
- const linkedIds = new Set();
11951
- for (const n of fNodesC) {
11952
- for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedIds.add(n.id); linkedIds.add(x); }
11953
- }
11954
- const isolated = fNodesC.length ? (fNodesC.length - linkedIds.size) : 0;
11955
- jsonResult.featureGraph = {
11956
- total: fNodesC.length,
11957
- edges: edgeCount,
11958
- isolated: Math.max(0, isolated),
11959
- summary: `F${fNodesC.length}/E${edgeCount}${isolated > 0 ? `/iso${isolated}` : ''}`
11960
- };
11961
- } catch {}
11962
- } catch {}
11963
-
11964
- // 1.9.217: session close 자동 통합 — 1.9.207 + 1.9.209 + 1.9.212
11965
- // 마감 시 미답 요청 / pre-wake audit / 멱등성 검사 자동 실행 + JSON 통합
11966
- try {
11967
- // 1.9.207: 미답 사용자 요청 audit
11968
- const reqAudit = _auditUserRequests(root);
11969
- jsonResult.userRequestsAudit = {
11970
- total: reqAudit.total,
11971
- open: reqAudit.open,
11972
- missing: reqAudit.missing ? reqAudit.missing.length : 0,
11973
- tracked: reqAudit.tracked ? reqAudit.tracked.length : 0,
11974
- stale: reqAudit.stale ? reqAudit.stale.length : 0
11975
- };
11976
- // 1.9.223: delivered 패턴 자동 감지 통합
11977
- try {
11978
- const delivered = _detectDeliveredRequests(root);
11979
- jsonResult.deliveredRequests = {
11980
- candidates: delivered.candidates.length,
11981
- currentVersion: delivered.currentVersion,
11982
- autoCompleteAvailable: delivered.candidates.length > 0
11983
- };
11984
- } catch {}
11985
- // 1.9.227: roundHistory 통합 (session close JSON 6번째 통합 필드)
11986
- try {
11987
- const rh = _computeRoundHistory(root);
11988
- jsonResult.roundHistory = {
11989
- roundCount: rh.roundCount,
11990
- baselineVersion: rh.baselineVersion,
11991
- nextMilestone: rh.nextMilestone,
11992
- roundsToNextMilestone: rh.roundsToNextMilestone,
11993
- daysActive: rh.daysActive,
11994
- avgRoundsPerDay: rh.avgRoundsPerDay
11995
- };
11996
- } catch {}
11997
- // 1.9.230: milestones 통합 (session close JSON 7번째 통합 필드)
11998
- try {
11999
- const ms = _computeMilestones(root);
12000
- jsonResult.milestones = {
12001
- reachedCount: ms.reached.length,
12002
- reached: ms.reached.map(m => ({ milestone: m.milestone, version: m.version, reachedAt: m.reachedAt })),
12003
- next: ms.next,
12004
- avgRoundsPerDay: ms.avgRoundsPerDay
12005
- };
12006
- } catch {}
12007
- // 1.9.234: recentChanges 통합 (session close JSON 8번째 통합 필드) — 최근 5 라운드 변경
12008
- try {
12009
- jsonResult.recentChanges = _computeRecentChanges(root, 5);
12010
- } catch {}
12011
- // 1.9.240: pyFiles 통합 (session close JSON 9번째 통합 필드) — UR-0013 2단계
12012
- try {
12013
- const pyFiles = _collectPyFiles(root, 200);
12014
- const analyses = pyFiles.slice(0, 200).map(f => _analyzePyFile(f)).filter(Boolean);
12015
- jsonResult.pyFiles = {
12016
- total: pyFiles.length,
12017
- analyzed: analyses.length,
12018
- totalLOC: analyses.reduce((s, a) => s + a.loc, 0),
12019
- totalImports: analyses.reduce((s, a) => s + a.imports, 0),
12020
- totalFuncs: analyses.reduce((s, a) => s + a.funcs, 0),
12021
- totalClasses: analyses.reduce((s, a) => s + a.classes, 0)
12022
- };
12023
- } catch {}
12024
- // 1.9.242: envInfo 통합 (session close JSON 10번째 통합 필드) — UR-0014 2단계
12025
- try {
12026
- const runtimeEnv = _collectRuntimeEnv();
12027
- const encScan = _scanShellScriptsEncoding(root);
12028
- jsonResult.envInfo = {
12029
- os: runtimeEnv.os.platform,
12030
- isKoreanWindows: runtimeEnv.locale.isKoreanWindows || false,
12031
- codepage: runtimeEnv.locale.codepage || null,
12032
- nodeVersion: runtimeEnv.node.version,
12033
- shellScriptsScanned: encScan.scanned,
12034
- encodingRiskCount: encScan.atRisk.length,
12035
- encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
12036
- // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
12037
- terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
12038
- autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
12039
- // 1.9.250 (UR-0018 2단계): POSIX (Linux/macOS/WSL) terminal encoding 점검
12040
- posixEncodingOk: runtimeEnv.locale.posixEncodingOk,
12041
- isWSL: runtimeEnv.locale.isWSL || false
12042
- };
12043
- } catch {}
12044
- // 1.9.245: apiSkills 통합 (session close JSON 11번째 통합 필드) — UR-0015
12045
- try {
12046
- const allSkills = _listAPISkills(root);
12047
- let currentTaskText = '';
12048
- try {
12049
- const rows = readProgressRows(root);
12050
- const ip = rows.find(r => r.status === 'in-progress');
12051
- if (ip) currentTaskText = (ip.title || '') + ' ' + (ip.notes || '');
12052
- } catch {}
12053
- const matched = currentTaskText ? _matchAPISkills(root, currentTaskText) : [];
12054
- jsonResult.apiSkills = {
12055
- total: allSkills.length,
12056
- matched: matched.length,
12057
- matchedIds: matched.slice(0, 5).map(s => s.id),
12058
- ids: allSkills.slice(0, 10).map(s => s.id)
12059
- };
12060
- } catch {}
12061
- // 1.9.264: shellGuard 통합 (session close JSON 12번째 통합 필드) — UR-0020 셸 실패 메모리 + 환경 변동
12062
- try {
12063
- const sf = _loadShellFailures(root);
12064
- const drift = _shellEnvDrift(root);
12065
- jsonResult.shellGuard = {
12066
- failureCount: sf.failures.length,
12067
- recent: sf.failures.slice(-3).map(f => ({ cmd: (f.cmd || '').slice(0, 50), exitCode: f.exitCode, shell: f.shell, rules: f.issues || [] })),
12068
- envDriftChanges: drift && drift.changes ? drift.changes.length : 0,
12069
- envDrift: drift ? drift.changes : null
12070
- };
12071
- } catch {}
12072
- } catch {}
12073
- try {
12074
- // 1.9.209: pre-wake-audit 자동 실행 + 저장 (sleep 전 자동 점검)
12075
- if (!opts.noPreWake && !has('--no-pre-wake')) {
12076
- const audit = _runPreWakeAudit(root);
12077
- _saveAndAppendPreWakeReport(root, audit);
12078
- jsonResult.preWakeAudit = {
12079
- auditedAt: audit.auditedAt,
12080
- critical: audit.summary.criticalCount,
12081
- warning: audit.summary.warningCount,
12082
- info: audit.summary.infoCount,
12083
- needsAttention: audit.summary.needsAttention
12084
- };
12085
- }
12086
- } catch {}
12087
- try {
12088
- // 1.9.212: 멱등성 검사 자동 실행 (rule/task/user-requests/wakeups 4영역)
12089
- const idemp = _runIdempotencyAudit(root);
12090
- jsonResult.idempotencyAudit = {
12091
- violations: idemp.summary.totalViolations,
12092
- high: idemp.summary.highSeverity,
12093
- medium: idemp.summary.mediumSeverity,
12094
- low: idemp.summary.lowSeverity,
12095
- verified: idemp.summary.verifiedAreas,
12096
- overall: idemp.summary.overall
12097
- };
12098
- } catch {}
12099
- try {
12100
- // 1.9.221: abnormalShutdown 자동 감지 (1.9.220 통합) — session close 시 다음 재개 가이드 회수
12101
- const ad = _detectAbnormalShutdown(root);
12102
- jsonResult.abnormalShutdown = {
12103
- detected: ad.abnormalShutdown,
12104
- severity: ad.severity,
12105
- signalCount: ad.signals.length,
12106
- signals: ad.signals.map(s => ({ kind: s.kind, severity: s.severity, detail: s.detail })),
12107
- resumeGuide: ad.resumeGuide
12108
- };
12109
- } catch {}
12110
-
12111
- process.stdout.write(JSON.stringify(jsonResult, null, 2) + '\n');
12112
- } else {
12113
- // 1.9.217: human 출력 모드에서도 통합 보고 노출 (마감 직전)
12114
- try {
12115
- const isTty = process.stdout.isTTY;
12116
- const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
12117
- const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
12118
- const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
12119
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
12120
-
12121
- log('');
12122
- log(`## 🔚 session close 자동 통합 보고 (1.9.217)`);
12123
- // 1.9.207 + 1.9.223 (delivered 패턴 자동 권장) + 1.9.224 (--auto-apply-delivered 옵션)
12124
- try {
12125
- const reqAudit = _auditUserRequests(root);
12126
- const missCnt = reqAudit.missing ? reqAudit.missing.length : 0;
12127
- let delivered = { candidates: [] };
12128
- try { delivered = _detectDeliveredRequests(root); } catch {}
12129
- if (delivered.candidates && delivered.candidates.length > 0) {
12130
- if (has('--auto-apply-delivered')) {
12131
- // 1.9.224: 자동 정리 (마감 시 호출 — 안전: 패턴 매칭 + 버전 가드)
12132
- let ok = 0;
12133
- for (const c of delivered.candidates) {
12134
- const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'session-close-auto-apply-1.9.224' });
12135
- if (u) ok++;
12136
- }
12137
- log(grn(` ✓ delivered 패턴 ${ok}건 자동 완료 (--auto-apply-delivered 1.9.224)`));
12138
- } else {
12139
- log(yel(` 📥 delivered 패턴 ${delivered.candidates.length}건 (1.9.223) — 자동 완료 가능`));
12140
- log(dim(` → leerness requests auto-complete --apply (수동) 또는 session close --auto-apply-delivered (1.9.224)`));
12141
- }
12142
- } else if (missCnt > 0) {
12143
- log(red(` ⚠ 미답 사용자 요청 ${missCnt}건 (task-log/plan/decisions 매칭 안 됨)`));
12144
- } else if (reqAudit.open > 0) {
12145
- log(grn(` ✓ 사용자 요청 ${reqAudit.open}건 모두 tracked`));
12146
- } else {
12147
- log(dim(` ℹ 사용자 요청 없음 (UR 백로그 비어있음)`));
12148
- }
12149
- } catch {}
12150
- // 1.9.209
12151
- try {
12152
- if (!opts.noPreWake && !has('--no-pre-wake')) {
12153
- const audit = _runPreWakeAudit(root);
12154
- _saveAndAppendPreWakeReport(root, audit);
12155
- const sum = audit.summary;
12156
- if (sum.criticalCount > 0) {
12157
- log(red(` 🚨 pre-wake-audit: critical ${sum.criticalCount} (다음 깨어남 시 점검 필요)`));
12158
- } else if (sum.warningCount > 0) {
12159
- log(yel(` ⚠ pre-wake-audit: warning ${sum.warningCount}`));
12160
- } else {
12161
- log(grn(` ✓ pre-wake-audit: clean (sleep 안전)`));
12162
- }
12163
- }
12164
- } catch {}
12165
- // 1.9.212
12166
- try {
12167
- const idemp = _runIdempotencyAudit(root);
12168
- const v = idemp.summary.totalViolations;
12169
- if (v > 0) {
12170
- log(red(` ⚠ 멱등성 위반 ${v}건 (high: ${idemp.summary.highSeverity})`));
12171
- log(dim(` → leerness idempotency audit 으로 상세 확인`));
12172
- } else {
12173
- log(grn(` ✓ 멱등성 검사 통과 — verified ${idemp.summary.verifiedAreas} 영역`));
12174
- }
12175
- } catch {}
12176
- // 1.9.264: 셸 실패 메모리 + 환경 변동 요약 (UR-0020) — 마감 시 이번 세션 셸 실패를 회고에 노출
12177
- try {
12178
- const sf = _loadShellFailures(root);
12179
- const drift = _shellEnvDrift(root);
12180
- const driftN = drift && drift.changes ? drift.changes.length : 0;
12181
- if (sf.failures.length > 0 || driftN > 0) {
12182
- if (driftN > 0) log(yel(` ⚠ 환경 버전 변동 ${driftN}건 — 다음 세션 셸 실패 기록 재검토 권장`));
12183
- if (sf.failures.length > 0) {
12184
- log(yel(` 🐚 셸 실패 누적 ${sf.failures.length}건 — 다음 handoff 가 자동 노출`));
12185
- log(dim(` → 명령 실행 전 점검: leerness shell-guard "<command>"`));
12186
- }
12187
- } else {
12188
- log(grn(` ✓ 셸 실패 기록 없음 (터미널 호환성 양호)`));
12189
- }
12190
- } catch {}
12191
- // 1.9.237: session close --auto-cleanup-branches — 50+ release/* branches 시 자동 정리
12192
- // 1.9.224 패턴 (--auto-apply-delivered) 확장 — 마감 시 운영 누적 폐기물 자동 정리
12193
- // 안전: keep 10, merged 만, 현재 branch 보호
12194
- try {
12195
- const branchR = cp.spawnSync('git', ['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
12196
- if (branchR.status === 0) {
12197
- const merged = (branchR.stdout || '').split('\n')
12198
- .map(l => l.replace(/^\*?\s+/, '').trim())
12199
- .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
12200
- if (merged.length > 50) {
12201
- if (has('--auto-cleanup-branches')) {
12202
- merged.sort((a, b) => {
12203
- const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
12204
- const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
12205
- for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
12206
- return 0;
12207
- });
12208
- const curR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
12209
- const cur = (curR.stdout || '').trim();
12210
- const toDelete = merged.slice(10).filter(b => b !== cur);
12211
- let okCnt = 0;
12212
- for (const b of toDelete) {
12213
- const r = cp.spawnSync('git', ['branch', '-d', b], { cwd: root, encoding: 'utf8' });
12214
- if (r.status === 0) okCnt++;
12215
- }
12216
- log(grn(` ✓ release 정리 ${okCnt}/${toDelete.length}건 (--auto-cleanup-branches 1.9.237, keep 10)`));
12217
- } else {
12218
- log(yel(` 🗑 release/* merged ${merged.length}개 (50+) — cleanup 가능 (1.9.235)`));
12219
- log(dim(` → leerness release cleanup --apply --keep 10 (수동)`));
12220
- log(dim(` → 또는 session close --auto-cleanup-branches (1.9.237 자동)`));
12221
- }
12222
- }
12223
- }
12224
- } catch {}
12225
- // 1.9.243: session close --auto-fix-encoding — 셸 스크립트 인코딩 위험 자동 회복 (UR-0014 3단계)
12226
- // 1.9.224 (--auto-apply-delivered) / 1.9.237 (--auto-cleanup-branches) 패턴 확장
12227
- // 마감 시 한국어/일본어/중국어 PowerShell 인코딩 위험 자동 BOM 추가
12228
- try {
12229
- const encScan = _scanShellScriptsEncoding(root);
12230
- if (encScan.atRisk && encScan.atRisk.length > 0) {
12231
- if (has('--auto-fix-encoding')) {
12232
- let ok = 0;
12233
- for (const r of encScan.atRisk) {
12234
- try {
12235
- const fullPath = path.join(root, r.file);
12236
- const orig = fs.readFileSync(fullPath);
12237
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
12238
- const fixed = Buffer.concat([bom, orig]);
12239
- fs.writeFileSync(fullPath, fixed);
12240
- ok++;
12241
- } catch {}
12242
- }
12243
- log(grn(` ✓ 인코딩 위험 ${ok}/${encScan.atRisk.length}건 UTF-8 BOM 자동 추가 (--auto-fix-encoding 1.9.243)`));
12244
- } else {
12245
- log(yel(` ⚠ 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 (1.9.241) — 자동 회복 가능`));
12246
- log(dim(` → leerness env encoding --apply (수동) 또는 session close --auto-fix-encoding (1.9.243 자동)`));
12247
- }
12248
- }
12249
- } catch {}
12250
- // 1.9.232: 마감 시 pulse 한 줄 자동 노출 — 다음 라운드 진입 시 즉시 상태 인지
12251
- try {
12252
- const rh = _computeRoundHistory(root);
12253
- const ms = _computeMilestones(root);
12254
- const rows = readProgressRows(root);
12255
- const tIn = rows.filter(r => r.status === 'in-progress').length;
12256
- const dCnt = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
12257
- const rActive = readRules(root).filter(r => r.status === 'active').length;
12258
- const planText = exists(planPath(root)) ? read(planPath(root)) : '';
12259
- const pCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
12260
- const lCnt = _loadLessons(root).length;
12261
- const mem = `T${tIn}/D${dCnt}/R${rActive}/P${pCnt}/L${lCnt}`;
12262
- let pulseLine = `📍 v${VERSION} · 🔄 R${rh.roundCount} · 🧠 ${mem}`;
12263
- if (ms.next) {
12264
- const eta = ms.next.etaDays != null ? ` (${ms.next.etaDays}d)` : '';
12265
- pulseLine += ` · 🎯 R${ms.next.milestone}${eta}`;
12266
- }
12267
- log('');
12268
- log(` ${pulseLine} ${dim('— leerness pulse (1.9.232)')}`);
12269
- } catch {}
12270
- } catch {}
12271
- }
12272
- }
11256
+ const _sessionClose = require('../lib/session-close');
11257
+ // 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
11258
+ function sessionClose(root, opts = {}) { return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest }); }
12273
11259
 
12274
11260
  function readmeCmd(root) { syncReadme(absRoot(root)); }
12275
11261
  function consistencyCheck(root) {