leerness 1.36.66 → 1.36.67

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.
@@ -1,647 +1,647 @@
1
- // lib/session-close.js — session close 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째, 1.9.425)
2
- // bin/leerness.js 에서 sessionClose(599줄) 분리. DI: harness 고유 의존 다수 주입.
3
- // io 프리미티브는 ./io, _sanitizeFences/_parseArchiveBlocks 는 ./pure-utils, cp/os/path/fs 빌트인.
4
- // __filename→harnessPath(self-spawn). 동작/출력 무변경(9 카테고리 + 활성 룰 검증 + retro 등).
5
- 'use strict';
6
- const cp = require('child_process');
7
- const os = require('os');
8
- const path = require('path');
9
- const fs = require('fs');
10
- const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
11
- const { _sanitizeFences, _parseArchiveBlocks } = require('./pure-utils');
12
-
13
- function sessionClose(root, opts = {}, deps = {}) {
14
- const { VERSION, STATUSES, MARK, has, arg, uiLang, harnessPath, 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, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings } = deps;
15
- // 1.22.1 (UR-0010 Phase 4): 마감 보고 UI 언어 — 영어 opt-in, 한국어 기본(uiLang 미주입 시 ko).
16
- const t = (ko, en) => (uiLang === 'en' ? en : ko);
17
- root = absRoot(root);
18
- // 1.10.4 (13th 버그헌트 P2, UR-0167): 경로 없음/디렉토리 아님 → 구조화 에러 + exit 1. mkdir <path>/.harness ENOTDIR 크래시 & 실패를 성공(exit 0)으로 오판하던 문제 차단.
19
- if (!exists(root) || !fs.statSync(root).isDirectory()) { failJson(!!opts.json || has('--json'), 'path_not_found', `경로 없음 또는 디렉토리 아님: ${root}`); return; }
20
- // 1.9.103: --json 모드 — stdout 억제 후 구조화 출력
21
- const jsonMode = !!opts.json || has('--json');
22
- const _origWrite = process.stdout.write.bind(process.stdout);
23
- if (jsonMode) process.stdout.write = () => true;
24
- const jsonResult = { version: VERSION, root, closedAt: now() };
25
- try {
26
- const rows = readProgressRows(root);
27
- const buckets = {};
28
- for (const s of STATUSES) buckets[s] = [];
29
- // 1.36.34 (codex 3차 #10): 별칭/미지 상태가 어느 카테고리에도 안 담겨 마감 보고에서 조용히 사라짐
30
- // (completed/cancelled 3건 중 1건만 집계 실측). 별칭은 정규화, 미지는 'other' 버킷으로 — 소실 0.
31
- const _STATUS_ALIAS = { completed: 'done', complete: 'done', finished: 'done', verified: 'done', cancelled: 'dropped', canceled: 'dropped', cancel: 'dropped', in_progress: 'in-progress', wip: 'in-progress', on_hold: 'on-hold', hold: 'on-hold' };
32
- buckets.other = [];
33
- for (const r of rows) {
34
- const s0 = String(r.status || '').toLowerCase().trim();
35
- const s = buckets[s0] ? s0 : (_STATUS_ALIAS[s0] && buckets[_STATUS_ALIAS[s0]] ? _STATUS_ALIAS[s0] : 'other');
36
- buckets[s].push(r);
37
- }
38
- // 1.9.103: JSON 결과 누적
39
- jsonResult.taskCounts = {};
40
- for (const s of STATUSES) jsonResult.taskCounts[s] = (buckets[s] || []).length;
41
- if (buckets.other.length) jsonResult.taskCounts.other = buckets.other.length;
42
- jsonResult.recommendedDirection = (buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || null;
43
- jsonResult.nextExactStep = (buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || null;
44
- // 1.12.3 (14th 버그헌트 P3, UR-0183): 마감 시 완료 정직성 advisory — done 인데 evidence 가 비었거나 placeholder 인 task 노출(차단 X, 정직성 환기). lazy detect 의 done_no_evidence 휴리스틱과 동일.
45
- const _doneNoEvidence = (buckets['done'] || []).filter(r => !r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4,}\s*$/.test(r.evidence));
46
- jsonResult.completionHonesty = { doneTotal: (buckets['done'] || []).length, doneWithoutEvidence: _doneNoEvidence.length, ids: _doneNoEvidence.slice(0, 5).map(r => r.id) };
47
- if (_doneNoEvidence.length) log(t(` ⚠ 완료 정직성: done ${_doneNoEvidence.length}건 evidence 없음/placeholder (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim 권장 (advisory)`, ` ⚠ completion honesty: ${_doneNoEvidence.length} done with no/placeholder evidence (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim recommended (advisory)`));
48
- // 1.17.6 (UR-0049 마감 정합): done 의 미해소 낙관 의심 재확인 — verify-claim 을 건너뛴 거짓 주장(evidence 에 API/DB 주장 있는데 코드 흔적 없음)이
49
- // 평범한 'done' 으로 마감을 무사 통과하던 것(5축 실증 P2: 거짓 DB 주장이 done 으로 마감, gate 실패 중 'clean' 선언) — 마감이 마지막 관문 역할을 하도록 재확인. advisory.
50
- let _doneOptimism = [];
51
- try {
52
- if (_detectOptimism && _scanCodeForPatterns && (buckets['done'] || []).length) {
53
- const _codeText = _scanCodeForPatterns(root);
54
- _doneOptimism = (buckets['done'] || []).map(r => ({ id: r.id, suspects: _detectOptimism(r.evidence || '', _codeText) || [] })).filter(x => x.suspects.length);
55
- }
56
- } catch {}
57
- jsonResult.completionHonesty.optimismUnresolved = _doneOptimism.map(x => ({ id: x.id, kinds: x.suspects.map(s => s.kind) }));
58
- if (_doneOptimism.length) log(t(` ⚠ 완료 정직성: done ${_doneOptimism.length}건 낙관 의심 미해소 (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence 주장 vs 코드 흔적 불일치, 마감 전 verify-claim 재확인 권장 (advisory)`, ` ⚠ completion honesty: ${_doneOptimism.length} done with unresolved optimism (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence claim vs code trace mismatch, re-check with verify-claim before closing (advisory)`));
59
- // 1.17.6 (UR-0049): 마감 보안 재확인 — 커밋 대상 시크릿이 살아있으면 'clean' 으로 마감하지 않도록 표면화. advisory(차단 X).
60
- let _closeSecrets = 0;
61
- try { if (_collectSecretFindings) _closeSecrets = ((_collectSecretFindings(root) || {}).committed || []).length; } catch {}
62
- jsonResult.closeSecurity = { committedSecrets: _closeSecrets };
63
- if (_closeSecrets) log(t(` 🚨 마감 보안: 커밋 대상 시크릿 ${_closeSecrets}건 미해소 — clean 아님, leerness scan secrets 확인 후 마감 권장`, ` 🚨 close security: ${_closeSecrets} committed secret(s) unresolved — not clean, run leerness scan secrets before closing`));
64
-
65
- function rowsToList(arr) {
66
- if (!arr || !arr.length) return t('- 없음', '- none'); // 1.24.1 (UR-0010): en 보고 본문 정합(헤더는 이미 영어)
67
- return arr.map(r => `- ${r.id} ${r.request} → next: ${r.nextAction}`).join('\n');
68
- }
69
-
70
- // 1.9.287 (Codex 리뷰 수렴): evidence 임베딩 시 코드펜스(```) 가 session-handoff.md 마크다운을 깨뜨리는 품질 버그 수정.
71
- const evidenceSummary = _sanitizeFences(exists(evidencePath(root)) ? (read(evidencePath(root)).split('\n').slice(-30).join('\n')) : '(no review-evidence.md)');
72
- const block = [
73
- `# Session Handoff`,
74
- ``,
75
- `Last generated: ${now()}`,
76
- ``,
77
- `## Completed`,
78
- rowsToList(buckets['done']),
79
- ``,
80
- `## In Progress`,
81
- rowsToList(buckets['in-progress']),
82
- ``,
83
- `## Incomplete / Waiting / On Hold / Blocked`,
84
- rowsToList([...(buckets['incomplete']||[]), ...(buckets['waiting']||[]), ...(buckets['on-hold']||[]), ...(buckets['blocked']||[]), ...(buckets.other||[])]), // 1.36.34 (#10): 미지 상태도 소실 없이 표기
85
- ``,
86
- `## Dropped`,
87
- rowsToList(buckets['dropped']),
88
- ``,
89
- `## Verification`,
90
- '```',
91
- evidenceSummary.trim() || '(empty)',
92
- '```',
93
- ``,
94
- `## Recommended Direction`,
95
- `- ${(buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '다음 우선순위를 사용자와 정합니다.'}`,
96
- ``,
97
- `## Next Exact Step`,
98
- `- ${(buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || '없음'}`,
99
- ``
100
- ].join('\n');
101
- const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
102
- // 1.9.316 (drift 마커 버그): 프론트매터는 파일이 '---' 로 시작할 때만 추출.
103
- // 이전: 본문의 '---'(수평선/구분자)을 프론트매터 종료로 오인 → 구 블록(구 'Last generated')을 보존 →
104
- // session-handoff.md 에 'Last generated' 중복 누적 → drift 가 첫(=구) 매치를 읽어 'session close 누락' 영구 오발화.
105
- let frontmatter = '';
106
- if (/^---\r?\n/.test(cur)) {
107
- const fmEnd = cur.indexOf('\n---\n', 4);
108
- if (fmEnd > 0) frontmatter = cur.slice(0, fmEnd + 5) + MARK + '\n';
109
- }
110
- writeUtf8(handoffPath(root), frontmatter + block);
111
-
112
- if (exists(currentStatePath(root))) {
113
- let cs = read(currentStatePath(root));
114
- cs = cs.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
115
- cs = cs.replace(/## Now\n[\s\S]*?(?=\n## Next)/, `## Now\n- ${(buckets['in-progress'][0]?.request) || '대기 중'}\n`);
116
- cs = cs.replace(/## Next\n[\s\S]*?(?=\n## Blockers)/, `## Next\n- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}\n`);
117
- cs = cs.replace(/## Blockers\n[\s\S]*$/, `## Blockers\n${(buckets['blocked']||[]).map(b=>`- ${b.id} ${b.request}`).join('\n') || '-'}\n`);
118
- writeUtf8(currentStatePath(root), cs);
119
- }
120
-
121
- append(taskLogPath(root), `\n## ${today()} session-close\n- Generated session-handoff.md and refreshed current-state.md.\n`);
122
-
123
- log('# Session Close');
124
- log('## Task Lists');
125
- for (const s of STATUSES) {
126
- log(`\n### ${s}`);
127
- log(rowsToList(buckets[s]));
128
- }
129
- // 1.9.8: 룰 검증 자동 수행 + 보고
130
- const ruleResults = verifyRules(root);
131
- jsonResult.rules = ruleResults.map(r => ({ id: r.id, trigger: r.trigger, verified: r.verified, note: r.note }));
132
- log('\n## ⚡ User Rules verification');
133
- if (!ruleResults.length) log(t('- 활성 룰 없음', '- no active rules'));
134
- else {
135
- log('| ID | Trigger | Rule | Verified | Note |');
136
- log('|---|---|---|---|---|');
137
- const ic = { pass: '✓ pass', pending: '⓿ pending', manual: 'ⓘ manual', baseline: '○ baseline' };
138
- for (const r of ruleResults) log(`| ${r.id} | ${r.trigger} | ${r.rule.slice(0, 40)} | ${ic[r.verified] || '?'} | ${r.note} |`);
139
- }
140
- log('\n## Required final response sections');
141
- log(t('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과', '- Completed tasks\n- In-progress tasks\n- Incomplete/planned/waiting/on-hold/blocked/dropped tasks\n- Verification results\n- Recommended direction\n- Next exact step\n- ⚡ Per-rule verification results'));
142
- ok(`session-handoff.md and current-state.md updated`);
143
- // 1.9.12: session close 끝에 roadmap.html 자동 갱신
144
- _autoRoadmap(root, 'session-close');
145
- // 1.9.57: --suggest 옵션 — 마감 시 skill suggest + drift check + lessons 통합 보고
146
- // 1.9.59: default 활성 — --no-suggest로 명시 비활성 가능
147
- const suggestEnabled = (has('--suggest') || (!has('--no-suggest') && process.env.LEERNESS_NO_SUGGEST !== '1'));
148
- if (suggestEnabled) {
149
- const isTty = process.stdout && process.stdout.isTTY;
150
- const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
151
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
152
- log('');
153
- log(cy(t('## 💡 다음 라운드 추천 (1.9.57 --suggest)', '## 💡 Next-round suggestions (--suggest)')));
154
- // 1) skill suggest
155
- try {
156
- const r = cp.spawnSync(process.execPath, [harnessPath, 'skill', 'suggest', '--path', root, '--min', '3', '--json'],
157
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
158
- const j = JSON.parse(r.stdout);
159
- if (j.candidates && j.candidates.length) {
160
- log(dim(t(' 📌 신규 skill 후보 (Hermes-style 자동 학습):', ' 📌 New skill candidates (Hermes-style auto-learning):')));
161
- for (const c of j.candidates.slice(0, 3)) log(t(` • ${c.keyword} (${c.count}회 등장, 출처: ${c.source})`, ` • ${c.keyword} (seen ${c.count}x, source: ${c.source})`));
162
- jsonResult.skillCandidates = j.candidates.slice(0, 5);
163
- }
164
- } catch {}
165
- // 2) drift check
166
- try {
167
- const r = cp.spawnSync(process.execPath, [harnessPath, 'drift', 'check', root, '--json'],
168
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
169
- const j = JSON.parse(r.stdout.trim());
170
- if (j.level) {
171
- log(dim(t(` 🩺 drift 상태: ${j.level} ${j.score}/200`, ` 🩺 drift status: ${j.level} ${j.score}/200`)));
172
- if (j.fired && j.fired.length) log(dim(t(` 🔥 ${j.fired.length}건 임계 초과 — \`leerness drift check\` 상세`, ` 🔥 ${j.fired.length} over threshold — \`leerness drift check\` for details`)));
173
- jsonResult.drift = { level: j.level, score: j.score, fired: (j.fired || []).map(f => ({ label: f.label, weight: f.weight })) };
174
- }
175
- } catch {}
176
- // 3) usage stats top
177
- try {
178
- const stats = _readUsageStats(root);
179
- const entries = Object.entries(stats.commands || {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
180
- if (entries.length) {
181
- log(dim(t(` 📊 가장 많이 쓴 명령: ${entries.map(([c, n]) => `${c}(${n})`).join(', ')}`, ` 📊 Most-used commands: ${entries.map(([c, n]) => `${c}(${n})`).join(', ')}`)));
182
- jsonResult.topCommands = entries.map(([command, count]) => ({ command, count }));
183
- }
184
- // 1.9.74: MCP tools/call 통계 + rare 도구 노출
185
- if (stats.mcp && stats.mcp.tools) {
186
- const mcpEntries = Object.entries(stats.mcp.tools).sort((a, b) => b[1] - a[1]);
187
- if (mcpEntries.length) {
188
- const mcpTotal = mcpEntries.reduce((s, [, n]) => s + n, 0);
189
- log(dim(t(` 🔌 MCP 호출 (1.9.74): 총 ${mcpTotal}회, top: ${mcpEntries.slice(0, 3).map(([tool, n]) => `${tool}(${n})`).join(', ')}`, ` 🔌 MCP calls: ${mcpTotal} total, top: ${mcpEntries.slice(0, 3).map(([tool, n]) => `${tool}(${n})`).join(', ')}`)));
190
- const threshold = Math.max(1, Math.floor(mcpTotal * 0.05));
191
- const rare = mcpEntries.filter(([, n]) => n <= threshold).map(([tool]) => tool);
192
- if (rare.length && mcpTotal >= 5) log(dim(t(` 💡 드물게 호출된 MCP: ${rare.slice(0, 4).join(', ')}`, ` 💡 Rarely-called MCP: ${rare.slice(0, 4).join(', ')}`)));
193
- jsonResult.mcpStats = { total: mcpTotal, top: mcpEntries.slice(0, 5).map(([tool, count]) => ({ tool, count })), rare: rare.slice(0, 10) };
194
- }
195
- }
196
- } catch {}
197
- // 1.9.74: skill match query top (skill-suggestions.md 누적)
198
- try {
199
- const histPath = path.join(root, '.harness', 'skill-suggestions.md');
200
- if (exists(histPath)) {
201
- const histTxt = read(histPath);
202
- const queries = [];
203
- for (const block of histTxt.split(/\n(?=## )/)) {
204
- const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
205
- if (h) queries.push(h[2]);
206
- }
207
- if (queries.length) {
208
- // 같은 query 개수 카운트
209
- const counts = {};
210
- for (const q of queries) counts[q] = (counts[q] || 0) + 1;
211
- const topQueries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3);
212
- log(dim(t(` 📒 skill match query 누적 (1.9.74): 총 ${queries.length}회 / 종류 ${Object.keys(counts).length}개`, ` 📒 skill match queries: ${queries.length} total / ${Object.keys(counts).length} distinct`)));
213
- for (const [q, n] of topQueries) log(dim(` • "${q.slice(0, 50)}"${n > 1 ? t(` (${n}회)`, ` (${n}x)`) : ''}`));
214
- }
215
- }
216
- } catch {}
217
- log('');
218
- }
219
- // 1.9.13: 세션 카운터 + 자동 한 줄 요약 + 5세션마다 깊은 회고
220
- try {
221
- const sc = readSessionCounter(root);
222
- sc.count = (sc.count || 0) + 1;
223
- sc.lastCloseAt = now();
224
- writeSessionCounter(root, sc);
225
- const agg = _retroAggregate(root);
226
- log(t(`\n## 📈 진행 요약 (session #${sc.count})`, `\n## 📈 Progress summary (session #${sc.count})`));
227
- log(` ${_retroOneLine(agg, uiLang)}`); // 1.24.1 (UR-0010): 진행 요약 한 줄 en
228
- // 1.9.132: archive 활동 1줄 요약 — 마감 시점에 DELETE 활동 가시화 (handoff 7번째 회수와 symmetric)
229
- try {
230
- const hdSC = path.join(root, '.harness');
231
- const arc = { d: 0, l: 0, p: 0, total: 0 };
232
- for (const [k, f] of [['d', 'decisions.archive.md'], ['l', 'lessons.archive.md'], ['p', 'plan.archive.md']]) {
233
- const fp = path.join(hdSC, f);
234
- if (exists(fp)) {
235
- const entries = _parseArchiveBlocks(read(fp));
236
- arc[k] = entries.length;
237
- arc.total += entries.length;
238
- }
239
- }
240
- if (arc.total > 0) {
241
- log(t(` 🗑 archive 누적: D${arc.d}/L${arc.l}/P${arc.p} (${arc.total}건) — 복원 후보: leerness memory archive list`, ` 🗑 archive total: D${arc.d}/L${arc.l}/P${arc.p} (${arc.total}) — restore via: leerness memory archive list`));
242
- }
243
- } catch {}
244
- if (sc.count % 5 === 0) {
245
- log(t(`\n## 🔄 ${sc.count}세션 마일스톤 — 자동 회고 (5세션마다)`, `\n## 🔄 ${sc.count}-session milestone — auto retro (every 5 sessions)`));
246
- retroCmd(root);
247
- sc.lastDeepRetroAt = now();
248
- writeSessionCounter(root, sc);
249
- } else {
250
- const left = 5 - (sc.count % 5);
251
- log(t(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`, ` 💡 deep retro in ${left} session(s) — run \`leerness retro\` now to trigger immediately`));
252
- }
253
- // 1.9.16: 워크스페이스 안내 (다른 leerness 프로젝트가 있으면)
254
- try {
255
- const wsCands = [path.resolve(root, '_apps'), path.resolve(root, '..', '_apps')];
256
- let wsCount = 0;
257
- for (const base of wsCands) {
258
- if (!exists(base)) continue;
259
- try { if (!fs.statSync(base).isDirectory()) continue; } catch { continue; }
260
- for (const e of fs.readdirSync(base)) {
261
- try {
262
- const p = path.join(base, e);
263
- if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness')) && p !== root) wsCount++;
264
- } catch {}
265
- }
266
- }
267
- if (wsCount > 0) log(t(` 🌐 워크스페이스에 ${wsCount}개 다른 leerness 프로젝트 — \`leerness retro --all-apps\`로 통합 회고`, ` 🌐 ${wsCount} other leerness project(s) in workspace — \`leerness retro --all-apps\` for combined retro`));
268
- jsonResult.workspacePeers = wsCount;
269
- } catch {}
270
- } catch (e) {
271
- warn(t('retro 요약 실패: ', 'retro summary failed: ') + (e && e.message ? e.message : e));
272
- jsonResult.retroSummaryError = e && e.message ? e.message : String(e);
273
- }
274
- } finally {
275
- // 1.9.103: stdout 복원
276
- if (jsonMode) process.stdout.write = _origWrite;
277
- }
278
- // 1.9.103: JSON 모드 — 구조화 출력
279
- if (jsonMode) {
280
- try {
281
- const sc = readSessionCounter(root);
282
- jsonResult.sessionNumber = sc.count;
283
- } catch {}
284
- // 1.9.122: memorySurface 통합 (handoff --json 1.9.115 와 동일 패턴)
285
- try {
286
- const rows0 = readProgressRows(root);
287
- const tasksByStatus0 = {};
288
- for (const s of STATUSES) tasksByStatus0[s] = 0;
289
- for (const r of rows0) tasksByStatus0[r.status] = (tasksByStatus0[r.status] || 0) + 1;
290
- const tasksInProgress0 = tasksByStatus0['in-progress'] || 0;
291
- const decisionsCount0 = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
292
- const rules0 = readRules(root);
293
- const rulesActive0 = rules0.filter(r => r.status === 'active').length;
294
- const planText0 = exists(planPath(root)) ? read(planPath(root)) : '';
295
- const milestones0 = (planText0.match(/^### M-\d{4,}\./gm) || []).length;
296
- const lessonsCount0 = _loadLessons(root).length;
297
- // 1.9.130: archive 카운트 통합
298
- const archiveCountsS = { decisions: 0, lessons: 0, plan: 0, total: 0 };
299
- try {
300
- const hdS = path.join(root, '.harness');
301
- for (const [key, file] of [['decisions', 'decisions.archive.md'], ['lessons', 'lessons.archive.md'], ['plan', 'plan.archive.md']]) {
302
- const fpS = path.join(hdS, file);
303
- if (exists(fpS)) {
304
- const entries = _parseArchiveBlocks(read(fpS));
305
- archiveCountsS[key] = entries.length;
306
- archiveCountsS.total += entries.length;
307
- }
308
- }
309
- } catch {}
310
- jsonResult.memorySurface = {
311
- tasks: { inProgress: tasksInProgress0, total: rows0.length, byStatus: tasksByStatus0 },
312
- decisions: { count: decisionsCount0 },
313
- rules: { active: rulesActive0, total: rules0.length },
314
- plan: { milestones: milestones0 },
315
- lessons: { count: lessonsCount0 },
316
- archive: archiveCountsS, // 1.9.130
317
- summary: `T${tasksInProgress0}/D${decisionsCount0}/R${rulesActive0}/P${milestones0}/L${lessonsCount0}`,
318
- };
319
- // 1.9.142: featureCounts 통합 — session close JSON에 Feature Graph 통계
320
- try {
321
- const { nodes: fNodesC } = _readFeatureGraph(root);
322
- const edgeCount = fNodesC.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
323
- const linkedIds = new Set();
324
- for (const n of fNodesC) {
325
- for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedIds.add(n.id); linkedIds.add(x); }
326
- }
327
- const isolated = fNodesC.length ? (fNodesC.length - linkedIds.size) : 0;
328
- jsonResult.featureGraph = {
329
- total: fNodesC.length,
330
- edges: edgeCount,
331
- isolated: Math.max(0, isolated),
332
- summary: `F${fNodesC.length}/E${edgeCount}${isolated > 0 ? `/iso${isolated}` : ''}`
333
- };
334
- } catch {}
335
- } catch {}
336
-
337
- // 1.9.217: session close 자동 통합 — 1.9.207 + 1.9.209 + 1.9.212
338
- // 마감 시 미답 요청 / pre-wake audit / 멱등성 검사 자동 실행 + JSON 통합
339
- try {
340
- // 1.9.207: 미답 사용자 요청 audit
341
- const reqAudit = _auditUserRequests(root);
342
- jsonResult.userRequestsAudit = {
343
- total: reqAudit.total,
344
- open: reqAudit.open,
345
- missing: reqAudit.missing ? reqAudit.missing.length : 0,
346
- tracked: reqAudit.tracked ? reqAudit.tracked.length : 0,
347
- stale: reqAudit.stale ? reqAudit.stale.length : 0
348
- };
349
- // 1.9.223: delivered 패턴 자동 감지 통합
350
- try {
351
- const delivered = _detectDeliveredRequests(root);
352
- jsonResult.deliveredRequests = {
353
- candidates: delivered.candidates.length,
354
- currentVersion: delivered.currentVersion,
355
- autoCompleteAvailable: delivered.candidates.length > 0
356
- };
357
- } catch {}
358
- // 1.9.227: roundHistory 통합 (session close JSON 6번째 통합 필드)
359
- try {
360
- const rh = _computeRoundHistory(root);
361
- jsonResult.roundHistory = {
362
- roundCount: rh.roundCount,
363
- baselineVersion: rh.baselineVersion,
364
- nextMilestone: rh.nextMilestone,
365
- roundsToNextMilestone: rh.roundsToNextMilestone,
366
- daysActive: rh.daysActive,
367
- avgRoundsPerDay: rh.avgRoundsPerDay
368
- };
369
- } catch {}
370
- // 1.9.230: milestones 통합 (session close JSON 7번째 통합 필드)
371
- try {
372
- const ms = _computeMilestones(root);
373
- jsonResult.milestones = {
374
- reachedCount: ms.reached.length,
375
- reached: ms.reached.map(m => ({ milestone: m.milestone, version: m.version, reachedAt: m.reachedAt })),
376
- next: ms.next,
377
- avgRoundsPerDay: ms.avgRoundsPerDay
378
- };
379
- } catch {}
380
- // 1.9.234: recentChanges 통합 (session close JSON 8번째 통합 필드) — 최근 5 라운드 변경
381
- try {
382
- jsonResult.recentChanges = _computeRecentChanges(root, 5);
383
- } catch {}
384
- // 1.9.240: pyFiles 통합 (session close JSON 9번째 통합 필드) — UR-0013 2단계
385
- try {
386
- const pyFiles = _collectPyFiles(root, 200);
387
- const analyses = pyFiles.slice(0, 200).map(f => _analyzePyFile(f)).filter(Boolean);
388
- jsonResult.pyFiles = {
389
- total: pyFiles.length,
390
- analyzed: analyses.length,
391
- totalLOC: analyses.reduce((s, a) => s + a.loc, 0),
392
- totalImports: analyses.reduce((s, a) => s + a.imports, 0),
393
- totalFuncs: analyses.reduce((s, a) => s + a.funcs, 0),
394
- totalClasses: analyses.reduce((s, a) => s + a.classes, 0)
395
- };
396
- } catch {}
397
- // 1.9.242: envInfo 통합 (session close JSON 10번째 통합 필드) — UR-0014 2단계
398
- try {
399
- const runtimeEnv = _collectRuntimeEnv();
400
- const encScan = _scanShellScriptsEncoding(root);
401
- jsonResult.envInfo = {
402
- os: runtimeEnv.os.platform,
403
- isKoreanWindows: runtimeEnv.locale.isKoreanWindows || false,
404
- codepage: runtimeEnv.locale.codepage || null,
405
- nodeVersion: runtimeEnv.node.version,
406
- shellScriptsScanned: encScan.scanned,
407
- encodingRiskCount: encScan.atRisk.length,
408
- encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
409
- // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
410
- terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
411
- autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
412
- // 1.9.250 (UR-0018 2단계): POSIX (Linux/macOS/WSL) terminal encoding 점검
413
- posixEncodingOk: runtimeEnv.locale.posixEncodingOk,
414
- isWSL: runtimeEnv.locale.isWSL || false
415
- };
416
- } catch {}
417
- // 1.9.245: apiSkills 통합 (session close JSON 11번째 통합 필드) — UR-0015
418
- try {
419
- const allSkills = _listAPISkills(root);
420
- let currentTaskText = '';
421
- try {
422
- const rows = readProgressRows(root);
423
- const ip = rows.find(r => r.status === 'in-progress');
424
- if (ip) currentTaskText = (ip.title || '') + ' ' + (ip.notes || '');
425
- } catch {}
426
- const matched = currentTaskText ? _matchAPISkills(root, currentTaskText) : [];
427
- jsonResult.apiSkills = {
428
- total: allSkills.length,
429
- matched: matched.length,
430
- matchedIds: matched.slice(0, 5).map(s => s.id),
431
- ids: allSkills.slice(0, 10).map(s => s.id)
432
- };
433
- } catch {}
434
- // 1.9.264: shellGuard 통합 (session close JSON 12번째 통합 필드) — UR-0020 셸 실패 메모리 + 환경 변동
435
- try {
436
- const sf = _loadShellFailures(root);
437
- const drift = _shellEnvDrift(root);
438
- jsonResult.shellGuard = {
439
- failureCount: sf.failures.length,
440
- recent: sf.failures.slice(-3).map(f => ({ cmd: (f.cmd || '').slice(0, 50), exitCode: f.exitCode, shell: f.shell, rules: f.issues || [] })),
441
- envDriftChanges: drift && drift.changes ? drift.changes.length : 0,
442
- envDrift: drift ? drift.changes : null
443
- };
444
- } catch {}
445
- } catch {}
446
- try {
447
- // 1.9.209: pre-wake-audit 자동 실행 + 저장 (sleep 전 자동 점검)
448
- if (!opts.noPreWake && !has('--no-pre-wake')) {
449
- const audit = _runPreWakeAudit(root);
450
- _saveAndAppendPreWakeReport(root, audit);
451
- jsonResult.preWakeAudit = {
452
- auditedAt: audit.auditedAt,
453
- critical: audit.summary.criticalCount,
454
- warning: audit.summary.warningCount,
455
- info: audit.summary.infoCount,
456
- needsAttention: audit.summary.needsAttention
457
- };
458
- }
459
- } catch {}
460
- try {
461
- // 1.9.212: 멱등성 검사 자동 실행 (rule/task/user-requests/wakeups 4영역)
462
- const idemp = _runIdempotencyAudit(root);
463
- jsonResult.idempotencyAudit = {
464
- violations: idemp.summary.totalViolations,
465
- high: idemp.summary.highSeverity,
466
- medium: idemp.summary.mediumSeverity,
467
- low: idemp.summary.lowSeverity,
468
- verified: idemp.summary.verifiedAreas,
469
- overall: idemp.summary.overall
470
- };
471
- } catch {}
472
- try {
473
- // 1.9.221: abnormalShutdown 자동 감지 (1.9.220 통합) — session close 시 다음 재개 가이드 회수
474
- const ad = _detectAbnormalShutdown(root);
475
- jsonResult.abnormalShutdown = {
476
- detected: ad.abnormalShutdown,
477
- severity: ad.severity,
478
- signalCount: ad.signals.length,
479
- signals: ad.signals.map(s => ({ kind: s.kind, severity: s.severity, detail: s.detail })),
480
- resumeGuide: ad.resumeGuide
481
- };
482
- } catch {}
483
-
484
- process.stdout.write(JSON.stringify(jsonResult, null, 2) + '\n');
485
- } else {
486
- // 1.9.217: human 출력 모드에서도 통합 보고 노출 (마감 직전)
487
- try {
488
- const isTty = process.stdout.isTTY;
489
- const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
490
- const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
491
- const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
492
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
493
-
494
- log('');
495
- log(t(`## 🔚 session close 자동 통합 보고 (1.9.217)`, `## 🔚 session close integrated report`));
496
- // 1.9.207 + 1.9.223 (delivered 패턴 자동 권장) + 1.9.224 (--auto-apply-delivered 옵션)
497
- try {
498
- const reqAudit = _auditUserRequests(root);
499
- const missCnt = reqAudit.missing ? reqAudit.missing.length : 0;
500
- let delivered = { candidates: [] };
501
- try { delivered = _detectDeliveredRequests(root); } catch {}
502
- if (delivered.candidates && delivered.candidates.length > 0) {
503
- if (has('--auto-apply-delivered')) {
504
- // 1.9.224: 자동 정리 (마감 시 호출 — 안전: 패턴 매칭 + 버전 가드)
505
- let ok = 0;
506
- for (const c of delivered.candidates) {
507
- const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'session-close-auto-apply-1.9.224' });
508
- if (u) ok++;
509
- }
510
- log(grn(t(` ✓ delivered 패턴 ${ok}건 자동 완료 (--auto-apply-delivered 1.9.224)`, ` ✓ ${ok} delivered pattern(s) auto-completed (--auto-apply-delivered)`)));
511
- } else {
512
- log(yel(t(` 📥 delivered 패턴 ${delivered.candidates.length}건 (1.9.223) — 자동 완료 가능`, ` 📥 ${delivered.candidates.length} delivered pattern(s) — auto-completable`)));
513
- log(dim(t(` → leerness requests auto-complete --apply (수동) 또는 session close --auto-apply-delivered (1.9.224)`, ` → leerness requests auto-complete --apply (manual) or session close --auto-apply-delivered`)));
514
- }
515
- } else if (missCnt > 0) {
516
- log(red(t(` ⚠ 미답 사용자 요청 ${missCnt}건 (task-log/plan/decisions 매칭 안 됨)`, ` ⚠ ${missCnt} unanswered user request(s) (no task-log/plan/decisions match)`)));
517
- } else if (reqAudit.open > 0) {
518
- log(grn(t(` ✓ 사용자 요청 ${reqAudit.open}건 모두 tracked`, ` ✓ all ${reqAudit.open} user request(s) tracked`)));
519
- } else {
520
- log(dim(t(` ℹ 사용자 요청 없음 (UR 백로그 비어있음)`, ` ℹ no user requests (UR backlog empty)`)));
521
- }
522
- } catch {}
523
- // 1.9.209
524
- try {
525
- if (!opts.noPreWake && !has('--no-pre-wake')) {
526
- const audit = _runPreWakeAudit(root);
527
- _saveAndAppendPreWakeReport(root, audit);
528
- const sum = audit.summary;
529
- if (sum.criticalCount > 0) {
530
- log(red(t(` 🚨 pre-wake-audit: critical ${sum.criticalCount} (다음 깨어남 시 점검 필요)`, ` 🚨 pre-wake-audit: critical ${sum.criticalCount} (check before next wake)`)));
531
- } else if (sum.warningCount > 0) {
532
- log(yel(` ⚠ pre-wake-audit: warning ${sum.warningCount}`));
533
- } else {
534
- log(grn(t(` ✓ pre-wake-audit: clean (sleep 안전)`, ` ✓ pre-wake-audit: clean (safe to sleep)`)));
535
- }
536
- }
537
- } catch {}
538
- // 1.9.212
539
- try {
540
- const idemp = _runIdempotencyAudit(root);
541
- const v = idemp.summary.totalViolations;
542
- if (v > 0) {
543
- log(red(t(` ⚠ 멱등성 위반 ${v}건 (high: ${idemp.summary.highSeverity})`, ` ⚠ ${v} idempotency violation(s) (high: ${idemp.summary.highSeverity})`)));
544
- log(dim(t(` → leerness idempotency audit 으로 상세 확인`, ` → see details with leerness idempotency audit`)));
545
- } else {
546
- log(grn(t(` ✓ 멱등성 검사 통과 — verified ${idemp.summary.verifiedAreas} 영역`, ` ✓ idempotency check passed — ${idemp.summary.verifiedAreas} area(s) verified`)));
547
- }
548
- } catch {}
549
- // 1.9.264: 셸 실패 메모리 + 환경 변동 요약 (UR-0020) — 마감 시 이번 세션 셸 실패를 회고에 노출
550
- try {
551
- const sf = _loadShellFailures(root);
552
- const drift = _shellEnvDrift(root);
553
- const driftN = drift && drift.changes ? drift.changes.length : 0;
554
- if (sf.failures.length > 0 || driftN > 0) {
555
- if (driftN > 0) log(yel(t(` ⚠ 환경 버전 변동 ${driftN}건 — 다음 세션 셸 실패 기록 재검토 권장`, ` ⚠ ${driftN} environment version change(s) — review shell-failure log next session`)));
556
- if (sf.failures.length > 0) {
557
- log(yel(t(` 🐚 셸 실패 누적 ${sf.failures.length}건 — 다음 handoff 가 자동 노출`, ` 🐚 ${sf.failures.length} accumulated shell failure(s) — surfaced by next handoff`)));
558
- log(dim(t(` → 명령 실행 전 점검: leerness shell-guard "<command>"`, ` → check before running: leerness shell-guard "<command>"`)));
559
- }
560
- } else {
561
- log(grn(t(` ✓ 셸 실패 기록 없음 (터미널 호환성 양호)`, ` ✓ no shell-failure records (terminal compatibility OK)`)));
562
- }
563
- } catch {}
564
- // 1.9.237: session close --auto-cleanup-branches — 50+ release/* branches 시 자동 정리
565
- // 1.9.224 패턴 (--auto-apply-delivered) 확장 — 마감 시 운영 누적 폐기물 자동 정리
566
- // 안전: keep 10, merged 만, 현재 branch 보호
567
- try {
568
- const branchR = cp.spawnSync('git', ['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
569
- if (branchR.status === 0) {
570
- const merged = (branchR.stdout || '').split('\n')
571
- .map(l => l.replace(/^\*?\s+/, '').trim())
572
- .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
573
- if (merged.length > 50) {
574
- if (has('--auto-cleanup-branches')) {
575
- merged.sort((a, b) => {
576
- const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
577
- const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
578
- for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
579
- return 0;
580
- });
581
- const curR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
582
- const cur = (curR.stdout || '').trim();
583
- const toDelete = merged.slice(10).filter(b => b !== cur);
584
- let okCnt = 0;
585
- for (const b of toDelete) {
586
- const r = cp.spawnSync('git', ['branch', '-d', b], { cwd: root, encoding: 'utf8' });
587
- if (r.status === 0) okCnt++;
588
- }
589
- log(grn(t(` ✓ release 정리 ${okCnt}/${toDelete.length}건 (--auto-cleanup-branches 1.9.237, keep 10)`, ` ✓ release cleanup ${okCnt}/${toDelete.length} (--auto-cleanup-branches, keep 10)`)));
590
- } else {
591
- log(yel(t(` 🗑 release/* merged ${merged.length}개 (50+) — cleanup 가능 (1.9.235)`, ` 🗑 ${merged.length} merged release/* branches (50+) — cleanup available`)));
592
- log(dim(t(` → leerness release cleanup --apply --keep 10 (수동)`, ` → leerness release cleanup --apply --keep 10 (manual)`)));
593
- log(dim(t(` → 또는 session close --auto-cleanup-branches (1.9.237 자동)`, ` → or session close --auto-cleanup-branches (auto)`)));
594
- }
595
- }
596
- }
597
- } catch {}
598
- // 1.9.243: session close --auto-fix-encoding — 셸 스크립트 인코딩 위험 자동 회복 (UR-0014 3단계)
599
- // 1.9.224 (--auto-apply-delivered) / 1.9.237 (--auto-cleanup-branches) 패턴 확장
600
- // 마감 시 한국어/일본어/중국어 PowerShell 인코딩 위험 자동 BOM 추가
601
- try {
602
- const encScan = _scanShellScriptsEncoding(root);
603
- if (encScan.atRisk && encScan.atRisk.length > 0) {
604
- if (has('--auto-fix-encoding')) {
605
- let ok = 0;
606
- for (const r of encScan.atRisk) {
607
- try {
608
- const fullPath = path.join(root, r.file);
609
- const orig = fs.readFileSync(fullPath);
610
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
611
- const fixed = Buffer.concat([bom, orig]);
612
- fs.writeFileSync(fullPath, fixed);
613
- ok++;
614
- } catch {}
615
- }
616
- log(grn(t(` ✓ 인코딩 위험 ${ok}/${encScan.atRisk.length}건 UTF-8 BOM 자동 추가 (--auto-fix-encoding 1.9.243)`, ` ✓ ${ok}/${encScan.atRisk.length} encoding risk(s) auto-fixed with UTF-8 BOM (--auto-fix-encoding)`)));
617
- } else {
618
- log(yel(t(` ⚠ 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 (1.9.241) — 자동 회복 가능`, ` ⚠ ${encScan.atRisk.length} shell-script encoding risk(s) — auto-fixable`)));
619
- log(dim(t(` → leerness env encoding --apply (수동) 또는 session close --auto-fix-encoding (1.9.243 자동)`, ` → leerness env encoding --apply (manual) or session close --auto-fix-encoding (auto)`)));
620
- }
621
- }
622
- } catch {}
623
- // 1.9.232: 마감 시 pulse 한 줄 자동 노출 — 다음 라운드 진입 시 즉시 상태 인지
624
- try {
625
- const rh = _computeRoundHistory(root);
626
- const ms = _computeMilestones(root);
627
- const rows = readProgressRows(root);
628
- const tIn = rows.filter(r => r.status === 'in-progress').length;
629
- const dCnt = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
630
- const rActive = readRules(root).filter(r => r.status === 'active').length;
631
- const planText = exists(planPath(root)) ? read(planPath(root)) : '';
632
- const pCnt = (planText.match(/^### M-\d{4,}\./gm) || []).length;
633
- const lCnt = _loadLessons(root).length;
634
- const mem = `T${tIn}/D${dCnt}/R${rActive}/P${pCnt}/L${lCnt}`;
635
- let pulseLine = `📍 v${VERSION} · 🔄 R${rh.roundCount} · 🧠 ${mem}`;
636
- if (ms.next) {
637
- const eta = ms.next.etaDays != null ? ` (${ms.next.etaDays}d)` : '';
638
- pulseLine += ` · 🎯 R${ms.next.milestone}${eta}`;
639
- }
640
- log('');
641
- log(` ${pulseLine} ${dim('— leerness pulse (1.9.232)')}`);
642
- } catch {}
643
- } catch {}
644
- }
645
- }
646
-
647
- module.exports = { sessionClose };
1
+ // lib/session-close.js — session close 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째, 1.9.425)
2
+ // bin/leerness.js 에서 sessionClose(599줄) 분리. DI: harness 고유 의존 다수 주입.
3
+ // io 프리미티브는 ./io, _sanitizeFences/_parseArchiveBlocks 는 ./pure-utils, cp/os/path/fs 빌트인.
4
+ // __filename→harnessPath(self-spawn). 동작/출력 무변경(9 카테고리 + 활성 룰 검증 + retro 등).
5
+ 'use strict';
6
+ const cp = require('child_process');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
11
+ const { _sanitizeFences, _parseArchiveBlocks } = require('./pure-utils');
12
+
13
+ function sessionClose(root, opts = {}, deps = {}) {
14
+ const { VERSION, STATUSES, MARK, has, arg, uiLang, harnessPath, 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, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings } = deps;
15
+ // 1.22.1 (UR-0010 Phase 4): 마감 보고 UI 언어 — 영어 opt-in, 한국어 기본(uiLang 미주입 시 ko).
16
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
17
+ root = absRoot(root);
18
+ // 1.10.4 (13th 버그헌트 P2, UR-0167): 경로 없음/디렉토리 아님 → 구조화 에러 + exit 1. mkdir <path>/.harness ENOTDIR 크래시 & 실패를 성공(exit 0)으로 오판하던 문제 차단.
19
+ if (!exists(root) || !fs.statSync(root).isDirectory()) { failJson(!!opts.json || has('--json'), 'path_not_found', `경로 없음 또는 디렉토리 아님: ${root}`); return; }
20
+ // 1.9.103: --json 모드 — stdout 억제 후 구조화 출력
21
+ const jsonMode = !!opts.json || has('--json');
22
+ const _origWrite = process.stdout.write.bind(process.stdout);
23
+ if (jsonMode) process.stdout.write = () => true;
24
+ const jsonResult = { version: VERSION, root, closedAt: now() };
25
+ try {
26
+ const rows = readProgressRows(root);
27
+ const buckets = {};
28
+ for (const s of STATUSES) buckets[s] = [];
29
+ // 1.36.34 (codex 3차 #10): 별칭/미지 상태가 어느 카테고리에도 안 담겨 마감 보고에서 조용히 사라짐
30
+ // (completed/cancelled 3건 중 1건만 집계 실측). 별칭은 정규화, 미지는 'other' 버킷으로 — 소실 0.
31
+ const _STATUS_ALIAS = { completed: 'done', complete: 'done', finished: 'done', verified: 'done', cancelled: 'dropped', canceled: 'dropped', cancel: 'dropped', in_progress: 'in-progress', wip: 'in-progress', on_hold: 'on-hold', hold: 'on-hold' };
32
+ buckets.other = [];
33
+ for (const r of rows) {
34
+ const s0 = String(r.status || '').toLowerCase().trim();
35
+ const s = buckets[s0] ? s0 : (_STATUS_ALIAS[s0] && buckets[_STATUS_ALIAS[s0]] ? _STATUS_ALIAS[s0] : 'other');
36
+ buckets[s].push(r);
37
+ }
38
+ // 1.9.103: JSON 결과 누적
39
+ jsonResult.taskCounts = {};
40
+ for (const s of STATUSES) jsonResult.taskCounts[s] = (buckets[s] || []).length;
41
+ if (buckets.other.length) jsonResult.taskCounts.other = buckets.other.length;
42
+ jsonResult.recommendedDirection = (buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || null;
43
+ jsonResult.nextExactStep = (buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || null;
44
+ // 1.12.3 (14th 버그헌트 P3, UR-0183): 마감 시 완료 정직성 advisory — done 인데 evidence 가 비었거나 placeholder 인 task 노출(차단 X, 정직성 환기). lazy detect 의 done_no_evidence 휴리스틱과 동일.
45
+ const _doneNoEvidence = (buckets['done'] || []).filter(r => !r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4,}\s*$/.test(r.evidence));
46
+ jsonResult.completionHonesty = { doneTotal: (buckets['done'] || []).length, doneWithoutEvidence: _doneNoEvidence.length, ids: _doneNoEvidence.slice(0, 5).map(r => r.id) };
47
+ if (_doneNoEvidence.length) log(t(` ⚠ 완료 정직성: done ${_doneNoEvidence.length}건 evidence 없음/placeholder (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim 권장 (advisory)`, ` ⚠ completion honesty: ${_doneNoEvidence.length} done with no/placeholder evidence (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim recommended (advisory)`));
48
+ // 1.17.6 (UR-0049 마감 정합): done 의 미해소 낙관 의심 재확인 — verify-claim 을 건너뛴 거짓 주장(evidence 에 API/DB 주장 있는데 코드 흔적 없음)이
49
+ // 평범한 'done' 으로 마감을 무사 통과하던 것(5축 실증 P2: 거짓 DB 주장이 done 으로 마감, gate 실패 중 'clean' 선언) — 마감이 마지막 관문 역할을 하도록 재확인. advisory.
50
+ let _doneOptimism = [];
51
+ try {
52
+ if (_detectOptimism && _scanCodeForPatterns && (buckets['done'] || []).length) {
53
+ const _codeText = _scanCodeForPatterns(root);
54
+ _doneOptimism = (buckets['done'] || []).map(r => ({ id: r.id, suspects: _detectOptimism(r.evidence || '', _codeText) || [] })).filter(x => x.suspects.length);
55
+ }
56
+ } catch {}
57
+ jsonResult.completionHonesty.optimismUnresolved = _doneOptimism.map(x => ({ id: x.id, kinds: x.suspects.map(s => s.kind) }));
58
+ if (_doneOptimism.length) log(t(` ⚠ 완료 정직성: done ${_doneOptimism.length}건 낙관 의심 미해소 (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence 주장 vs 코드 흔적 불일치, 마감 전 verify-claim 재확인 권장 (advisory)`, ` ⚠ completion honesty: ${_doneOptimism.length} done with unresolved optimism (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence claim vs code trace mismatch, re-check with verify-claim before closing (advisory)`));
59
+ // 1.17.6 (UR-0049): 마감 보안 재확인 — 커밋 대상 시크릿이 살아있으면 'clean' 으로 마감하지 않도록 표면화. advisory(차단 X).
60
+ let _closeSecrets = 0;
61
+ try { if (_collectSecretFindings) _closeSecrets = ((_collectSecretFindings(root) || {}).committed || []).length; } catch {}
62
+ jsonResult.closeSecurity = { committedSecrets: _closeSecrets };
63
+ if (_closeSecrets) log(t(` 🚨 마감 보안: 커밋 대상 시크릿 ${_closeSecrets}건 미해소 — clean 아님, leerness scan secrets 확인 후 마감 권장`, ` 🚨 close security: ${_closeSecrets} committed secret(s) unresolved — not clean, run leerness scan secrets before closing`));
64
+
65
+ function rowsToList(arr) {
66
+ if (!arr || !arr.length) return t('- 없음', '- none'); // 1.24.1 (UR-0010): en 보고 본문 정합(헤더는 이미 영어)
67
+ return arr.map(r => `- ${r.id} ${r.request} → next: ${r.nextAction}`).join('\n');
68
+ }
69
+
70
+ // 1.9.287 (Codex 리뷰 수렴): evidence 임베딩 시 코드펜스(```) 가 session-handoff.md 마크다운을 깨뜨리는 품질 버그 수정.
71
+ const evidenceSummary = _sanitizeFences(exists(evidencePath(root)) ? (read(evidencePath(root)).split('\n').slice(-30).join('\n')) : '(no review-evidence.md)');
72
+ const block = [
73
+ `# Session Handoff`,
74
+ ``,
75
+ `Last generated: ${now()}`,
76
+ ``,
77
+ `## Completed`,
78
+ rowsToList(buckets['done']),
79
+ ``,
80
+ `## In Progress`,
81
+ rowsToList(buckets['in-progress']),
82
+ ``,
83
+ `## Incomplete / Waiting / On Hold / Blocked`,
84
+ rowsToList([...(buckets['incomplete']||[]), ...(buckets['waiting']||[]), ...(buckets['on-hold']||[]), ...(buckets['blocked']||[]), ...(buckets.other||[])]), // 1.36.34 (#10): 미지 상태도 소실 없이 표기
85
+ ``,
86
+ `## Dropped`,
87
+ rowsToList(buckets['dropped']),
88
+ ``,
89
+ `## Verification`,
90
+ '```',
91
+ evidenceSummary.trim() || '(empty)',
92
+ '```',
93
+ ``,
94
+ `## Recommended Direction`,
95
+ `- ${(buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '다음 우선순위를 사용자와 정합니다.'}`,
96
+ ``,
97
+ `## Next Exact Step`,
98
+ `- ${(buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || '없음'}`,
99
+ ``
100
+ ].join('\n');
101
+ const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
102
+ // 1.9.316 (drift 마커 버그): 프론트매터는 파일이 '---' 로 시작할 때만 추출.
103
+ // 이전: 본문의 '---'(수평선/구분자)을 프론트매터 종료로 오인 → 구 블록(구 'Last generated')을 보존 →
104
+ // session-handoff.md 에 'Last generated' 중복 누적 → drift 가 첫(=구) 매치를 읽어 'session close 누락' 영구 오발화.
105
+ let frontmatter = '';
106
+ if (/^---\r?\n/.test(cur)) {
107
+ const fmEnd = cur.indexOf('\n---\n', 4);
108
+ if (fmEnd > 0) frontmatter = cur.slice(0, fmEnd + 5) + MARK + '\n';
109
+ }
110
+ writeUtf8(handoffPath(root), frontmatter + block);
111
+
112
+ if (exists(currentStatePath(root))) {
113
+ let cs = read(currentStatePath(root));
114
+ cs = cs.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
115
+ cs = cs.replace(/## Now\n[\s\S]*?(?=\n## Next)/, `## Now\n- ${(buckets['in-progress'][0]?.request) || '대기 중'}\n`);
116
+ cs = cs.replace(/## Next\n[\s\S]*?(?=\n## Blockers)/, `## Next\n- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}\n`);
117
+ cs = cs.replace(/## Blockers\n[\s\S]*$/, `## Blockers\n${(buckets['blocked']||[]).map(b=>`- ${b.id} ${b.request}`).join('\n') || '-'}\n`);
118
+ writeUtf8(currentStatePath(root), cs);
119
+ }
120
+
121
+ append(taskLogPath(root), `\n## ${today()} session-close\n- Generated session-handoff.md and refreshed current-state.md.\n`);
122
+
123
+ log('# Session Close');
124
+ log('## Task Lists');
125
+ for (const s of STATUSES) {
126
+ log(`\n### ${s}`);
127
+ log(rowsToList(buckets[s]));
128
+ }
129
+ // 1.9.8: 룰 검증 자동 수행 + 보고
130
+ const ruleResults = verifyRules(root);
131
+ jsonResult.rules = ruleResults.map(r => ({ id: r.id, trigger: r.trigger, verified: r.verified, note: r.note }));
132
+ log('\n## ⚡ User Rules verification');
133
+ if (!ruleResults.length) log(t('- 활성 룰 없음', '- no active rules'));
134
+ else {
135
+ log('| ID | Trigger | Rule | Verified | Note |');
136
+ log('|---|---|---|---|---|');
137
+ const ic = { pass: '✓ pass', pending: '⓿ pending', manual: 'ⓘ manual', baseline: '○ baseline' };
138
+ for (const r of ruleResults) log(`| ${r.id} | ${r.trigger} | ${r.rule.slice(0, 40)} | ${ic[r.verified] || '?'} | ${r.note} |`);
139
+ }
140
+ log('\n## Required final response sections');
141
+ log(t('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과', '- Completed tasks\n- In-progress tasks\n- Incomplete/planned/waiting/on-hold/blocked/dropped tasks\n- Verification results\n- Recommended direction\n- Next exact step\n- ⚡ Per-rule verification results'));
142
+ ok(`session-handoff.md and current-state.md updated`);
143
+ // 1.9.12: session close 끝에 roadmap.html 자동 갱신
144
+ _autoRoadmap(root, 'session-close');
145
+ // 1.9.57: --suggest 옵션 — 마감 시 skill suggest + drift check + lessons 통합 보고
146
+ // 1.9.59: default 활성 — --no-suggest로 명시 비활성 가능
147
+ const suggestEnabled = (has('--suggest') || (!has('--no-suggest') && process.env.LEERNESS_NO_SUGGEST !== '1'));
148
+ if (suggestEnabled) {
149
+ const isTty = process.stdout && process.stdout.isTTY;
150
+ const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
151
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
152
+ log('');
153
+ log(cy(t('## 💡 다음 라운드 추천 (1.9.57 --suggest)', '## 💡 Next-round suggestions (--suggest)')));
154
+ // 1) skill suggest
155
+ try {
156
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'skill', 'suggest', '--path', root, '--min', '3', '--json'],
157
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
158
+ const j = JSON.parse(r.stdout);
159
+ if (j.candidates && j.candidates.length) {
160
+ log(dim(t(' 📌 신규 skill 후보 (Hermes-style 자동 학습):', ' 📌 New skill candidates (Hermes-style auto-learning):')));
161
+ for (const c of j.candidates.slice(0, 3)) log(t(` • ${c.keyword} (${c.count}회 등장, 출처: ${c.source})`, ` • ${c.keyword} (seen ${c.count}x, source: ${c.source})`));
162
+ jsonResult.skillCandidates = j.candidates.slice(0, 5);
163
+ }
164
+ } catch {}
165
+ // 2) drift check
166
+ try {
167
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'drift', 'check', root, '--json'],
168
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
169
+ const j = JSON.parse(r.stdout.trim());
170
+ if (j.level) {
171
+ log(dim(t(` 🩺 drift 상태: ${j.level} ${j.score}/200`, ` 🩺 drift status: ${j.level} ${j.score}/200`)));
172
+ if (j.fired && j.fired.length) log(dim(t(` 🔥 ${j.fired.length}건 임계 초과 — \`leerness drift check\` 상세`, ` 🔥 ${j.fired.length} over threshold — \`leerness drift check\` for details`)));
173
+ jsonResult.drift = { level: j.level, score: j.score, fired: (j.fired || []).map(f => ({ label: f.label, weight: f.weight })) };
174
+ }
175
+ } catch {}
176
+ // 3) usage stats top
177
+ try {
178
+ const stats = _readUsageStats(root);
179
+ const entries = Object.entries(stats.commands || {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
180
+ if (entries.length) {
181
+ log(dim(t(` 📊 가장 많이 쓴 명령: ${entries.map(([c, n]) => `${c}(${n})`).join(', ')}`, ` 📊 Most-used commands: ${entries.map(([c, n]) => `${c}(${n})`).join(', ')}`)));
182
+ jsonResult.topCommands = entries.map(([command, count]) => ({ command, count }));
183
+ }
184
+ // 1.9.74: MCP tools/call 통계 + rare 도구 노출
185
+ if (stats.mcp && stats.mcp.tools) {
186
+ const mcpEntries = Object.entries(stats.mcp.tools).sort((a, b) => b[1] - a[1]);
187
+ if (mcpEntries.length) {
188
+ const mcpTotal = mcpEntries.reduce((s, [, n]) => s + n, 0);
189
+ log(dim(t(` 🔌 MCP 호출 (1.9.74): 총 ${mcpTotal}회, top: ${mcpEntries.slice(0, 3).map(([tool, n]) => `${tool}(${n})`).join(', ')}`, ` 🔌 MCP calls: ${mcpTotal} total, top: ${mcpEntries.slice(0, 3).map(([tool, n]) => `${tool}(${n})`).join(', ')}`)));
190
+ const threshold = Math.max(1, Math.floor(mcpTotal * 0.05));
191
+ const rare = mcpEntries.filter(([, n]) => n <= threshold).map(([tool]) => tool);
192
+ if (rare.length && mcpTotal >= 5) log(dim(t(` 💡 드물게 호출된 MCP: ${rare.slice(0, 4).join(', ')}`, ` 💡 Rarely-called MCP: ${rare.slice(0, 4).join(', ')}`)));
193
+ jsonResult.mcpStats = { total: mcpTotal, top: mcpEntries.slice(0, 5).map(([tool, count]) => ({ tool, count })), rare: rare.slice(0, 10) };
194
+ }
195
+ }
196
+ } catch {}
197
+ // 1.9.74: skill match query top (skill-suggestions.md 누적)
198
+ try {
199
+ const histPath = path.join(root, '.harness', 'skill-suggestions.md');
200
+ if (exists(histPath)) {
201
+ const histTxt = read(histPath);
202
+ const queries = [];
203
+ for (const block of histTxt.split(/\n(?=## )/)) {
204
+ const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
205
+ if (h) queries.push(h[2]);
206
+ }
207
+ if (queries.length) {
208
+ // 같은 query 개수 카운트
209
+ const counts = {};
210
+ for (const q of queries) counts[q] = (counts[q] || 0) + 1;
211
+ const topQueries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3);
212
+ log(dim(t(` 📒 skill match query 누적 (1.9.74): 총 ${queries.length}회 / 종류 ${Object.keys(counts).length}개`, ` 📒 skill match queries: ${queries.length} total / ${Object.keys(counts).length} distinct`)));
213
+ for (const [q, n] of topQueries) log(dim(` • "${q.slice(0, 50)}"${n > 1 ? t(` (${n}회)`, ` (${n}x)`) : ''}`));
214
+ }
215
+ }
216
+ } catch {}
217
+ log('');
218
+ }
219
+ // 1.9.13: 세션 카운터 + 자동 한 줄 요약 + 5세션마다 깊은 회고
220
+ try {
221
+ const sc = readSessionCounter(root);
222
+ sc.count = (sc.count || 0) + 1;
223
+ sc.lastCloseAt = now();
224
+ writeSessionCounter(root, sc);
225
+ const agg = _retroAggregate(root);
226
+ log(t(`\n## 📈 진행 요약 (session #${sc.count})`, `\n## 📈 Progress summary (session #${sc.count})`));
227
+ log(` ${_retroOneLine(agg, uiLang)}`); // 1.24.1 (UR-0010): 진행 요약 한 줄 en
228
+ // 1.9.132: archive 활동 1줄 요약 — 마감 시점에 DELETE 활동 가시화 (handoff 7번째 회수와 symmetric)
229
+ try {
230
+ const hdSC = path.join(root, '.harness');
231
+ const arc = { d: 0, l: 0, p: 0, total: 0 };
232
+ for (const [k, f] of [['d', 'decisions.archive.md'], ['l', 'lessons.archive.md'], ['p', 'plan.archive.md']]) {
233
+ const fp = path.join(hdSC, f);
234
+ if (exists(fp)) {
235
+ const entries = _parseArchiveBlocks(read(fp));
236
+ arc[k] = entries.length;
237
+ arc.total += entries.length;
238
+ }
239
+ }
240
+ if (arc.total > 0) {
241
+ log(t(` 🗑 archive 누적: D${arc.d}/L${arc.l}/P${arc.p} (${arc.total}건) — 복원 후보: leerness memory archive list`, ` 🗑 archive total: D${arc.d}/L${arc.l}/P${arc.p} (${arc.total}) — restore via: leerness memory archive list`));
242
+ }
243
+ } catch {}
244
+ if (sc.count % 5 === 0) {
245
+ log(t(`\n## 🔄 ${sc.count}세션 마일스톤 — 자동 회고 (5세션마다)`, `\n## 🔄 ${sc.count}-session milestone — auto retro (every 5 sessions)`));
246
+ retroCmd(root);
247
+ sc.lastDeepRetroAt = now();
248
+ writeSessionCounter(root, sc);
249
+ } else {
250
+ const left = 5 - (sc.count % 5);
251
+ log(t(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`, ` 💡 deep retro in ${left} session(s) — run \`leerness retro\` now to trigger immediately`));
252
+ }
253
+ // 1.9.16: 워크스페이스 안내 (다른 leerness 프로젝트가 있으면)
254
+ try {
255
+ const wsCands = [path.resolve(root, '_apps'), path.resolve(root, '..', '_apps')];
256
+ let wsCount = 0;
257
+ for (const base of wsCands) {
258
+ if (!exists(base)) continue;
259
+ try { if (!fs.statSync(base).isDirectory()) continue; } catch { continue; }
260
+ for (const e of fs.readdirSync(base)) {
261
+ try {
262
+ const p = path.join(base, e);
263
+ if (fs.statSync(p).isDirectory() && exists(path.join(p, '.harness')) && p !== root) wsCount++;
264
+ } catch {}
265
+ }
266
+ }
267
+ if (wsCount > 0) log(t(` 🌐 워크스페이스에 ${wsCount}개 다른 leerness 프로젝트 — \`leerness retro --all-apps\`로 통합 회고`, ` 🌐 ${wsCount} other leerness project(s) in workspace — \`leerness retro --all-apps\` for combined retro`));
268
+ jsonResult.workspacePeers = wsCount;
269
+ } catch {}
270
+ } catch (e) {
271
+ warn(t('retro 요약 실패: ', 'retro summary failed: ') + (e && e.message ? e.message : e));
272
+ jsonResult.retroSummaryError = e && e.message ? e.message : String(e);
273
+ }
274
+ } finally {
275
+ // 1.9.103: stdout 복원
276
+ if (jsonMode) process.stdout.write = _origWrite;
277
+ }
278
+ // 1.9.103: JSON 모드 — 구조화 출력
279
+ if (jsonMode) {
280
+ try {
281
+ const sc = readSessionCounter(root);
282
+ jsonResult.sessionNumber = sc.count;
283
+ } catch {}
284
+ // 1.9.122: memorySurface 통합 (handoff --json 1.9.115 와 동일 패턴)
285
+ try {
286
+ const rows0 = readProgressRows(root);
287
+ const tasksByStatus0 = {};
288
+ for (const s of STATUSES) tasksByStatus0[s] = 0;
289
+ for (const r of rows0) tasksByStatus0[r.status] = (tasksByStatus0[r.status] || 0) + 1;
290
+ const tasksInProgress0 = tasksByStatus0['in-progress'] || 0;
291
+ const decisionsCount0 = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
292
+ const rules0 = readRules(root);
293
+ const rulesActive0 = rules0.filter(r => r.status === 'active').length;
294
+ const planText0 = exists(planPath(root)) ? read(planPath(root)) : '';
295
+ const milestones0 = (planText0.match(/^### M-\d{4,}\./gm) || []).length;
296
+ const lessonsCount0 = _loadLessons(root).length;
297
+ // 1.9.130: archive 카운트 통합
298
+ const archiveCountsS = { decisions: 0, lessons: 0, plan: 0, total: 0 };
299
+ try {
300
+ const hdS = path.join(root, '.harness');
301
+ for (const [key, file] of [['decisions', 'decisions.archive.md'], ['lessons', 'lessons.archive.md'], ['plan', 'plan.archive.md']]) {
302
+ const fpS = path.join(hdS, file);
303
+ if (exists(fpS)) {
304
+ const entries = _parseArchiveBlocks(read(fpS));
305
+ archiveCountsS[key] = entries.length;
306
+ archiveCountsS.total += entries.length;
307
+ }
308
+ }
309
+ } catch {}
310
+ jsonResult.memorySurface = {
311
+ tasks: { inProgress: tasksInProgress0, total: rows0.length, byStatus: tasksByStatus0 },
312
+ decisions: { count: decisionsCount0 },
313
+ rules: { active: rulesActive0, total: rules0.length },
314
+ plan: { milestones: milestones0 },
315
+ lessons: { count: lessonsCount0 },
316
+ archive: archiveCountsS, // 1.9.130
317
+ summary: `T${tasksInProgress0}/D${decisionsCount0}/R${rulesActive0}/P${milestones0}/L${lessonsCount0}`,
318
+ };
319
+ // 1.9.142: featureCounts 통합 — session close JSON에 Feature Graph 통계
320
+ try {
321
+ const { nodes: fNodesC } = _readFeatureGraph(root);
322
+ const edgeCount = fNodesC.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
323
+ const linkedIds = new Set();
324
+ for (const n of fNodesC) {
325
+ for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedIds.add(n.id); linkedIds.add(x); }
326
+ }
327
+ const isolated = fNodesC.length ? (fNodesC.length - linkedIds.size) : 0;
328
+ jsonResult.featureGraph = {
329
+ total: fNodesC.length,
330
+ edges: edgeCount,
331
+ isolated: Math.max(0, isolated),
332
+ summary: `F${fNodesC.length}/E${edgeCount}${isolated > 0 ? `/iso${isolated}` : ''}`
333
+ };
334
+ } catch {}
335
+ } catch {}
336
+
337
+ // 1.9.217: session close 자동 통합 — 1.9.207 + 1.9.209 + 1.9.212
338
+ // 마감 시 미답 요청 / pre-wake audit / 멱등성 검사 자동 실행 + JSON 통합
339
+ try {
340
+ // 1.9.207: 미답 사용자 요청 audit
341
+ const reqAudit = _auditUserRequests(root);
342
+ jsonResult.userRequestsAudit = {
343
+ total: reqAudit.total,
344
+ open: reqAudit.open,
345
+ missing: reqAudit.missing ? reqAudit.missing.length : 0,
346
+ tracked: reqAudit.tracked ? reqAudit.tracked.length : 0,
347
+ stale: reqAudit.stale ? reqAudit.stale.length : 0
348
+ };
349
+ // 1.9.223: delivered 패턴 자동 감지 통합
350
+ try {
351
+ const delivered = _detectDeliveredRequests(root);
352
+ jsonResult.deliveredRequests = {
353
+ candidates: delivered.candidates.length,
354
+ currentVersion: delivered.currentVersion,
355
+ autoCompleteAvailable: delivered.candidates.length > 0
356
+ };
357
+ } catch {}
358
+ // 1.9.227: roundHistory 통합 (session close JSON 6번째 통합 필드)
359
+ try {
360
+ const rh = _computeRoundHistory(root);
361
+ jsonResult.roundHistory = {
362
+ roundCount: rh.roundCount,
363
+ baselineVersion: rh.baselineVersion,
364
+ nextMilestone: rh.nextMilestone,
365
+ roundsToNextMilestone: rh.roundsToNextMilestone,
366
+ daysActive: rh.daysActive,
367
+ avgRoundsPerDay: rh.avgRoundsPerDay
368
+ };
369
+ } catch {}
370
+ // 1.9.230: milestones 통합 (session close JSON 7번째 통합 필드)
371
+ try {
372
+ const ms = _computeMilestones(root);
373
+ jsonResult.milestones = {
374
+ reachedCount: ms.reached.length,
375
+ reached: ms.reached.map(m => ({ milestone: m.milestone, version: m.version, reachedAt: m.reachedAt })),
376
+ next: ms.next,
377
+ avgRoundsPerDay: ms.avgRoundsPerDay
378
+ };
379
+ } catch {}
380
+ // 1.9.234: recentChanges 통합 (session close JSON 8번째 통합 필드) — 최근 5 라운드 변경
381
+ try {
382
+ jsonResult.recentChanges = _computeRecentChanges(root, 5);
383
+ } catch {}
384
+ // 1.9.240: pyFiles 통합 (session close JSON 9번째 통합 필드) — UR-0013 2단계
385
+ try {
386
+ const pyFiles = _collectPyFiles(root, 200);
387
+ const analyses = pyFiles.slice(0, 200).map(f => _analyzePyFile(f)).filter(Boolean);
388
+ jsonResult.pyFiles = {
389
+ total: pyFiles.length,
390
+ analyzed: analyses.length,
391
+ totalLOC: analyses.reduce((s, a) => s + a.loc, 0),
392
+ totalImports: analyses.reduce((s, a) => s + a.imports, 0),
393
+ totalFuncs: analyses.reduce((s, a) => s + a.funcs, 0),
394
+ totalClasses: analyses.reduce((s, a) => s + a.classes, 0)
395
+ };
396
+ } catch {}
397
+ // 1.9.242: envInfo 통합 (session close JSON 10번째 통합 필드) — UR-0014 2단계
398
+ try {
399
+ const runtimeEnv = _collectRuntimeEnv();
400
+ const encScan = _scanShellScriptsEncoding(root);
401
+ jsonResult.envInfo = {
402
+ os: runtimeEnv.os.platform,
403
+ isKoreanWindows: runtimeEnv.locale.isKoreanWindows || false,
404
+ codepage: runtimeEnv.locale.codepage || null,
405
+ nodeVersion: runtimeEnv.node.version,
406
+ shellScriptsScanned: encScan.scanned,
407
+ encodingRiskCount: encScan.atRisk.length,
408
+ encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
409
+ // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
410
+ terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
411
+ autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
412
+ // 1.9.250 (UR-0018 2단계): POSIX (Linux/macOS/WSL) terminal encoding 점검
413
+ posixEncodingOk: runtimeEnv.locale.posixEncodingOk,
414
+ isWSL: runtimeEnv.locale.isWSL || false
415
+ };
416
+ } catch {}
417
+ // 1.9.245: apiSkills 통합 (session close JSON 11번째 통합 필드) — UR-0015
418
+ try {
419
+ const allSkills = _listAPISkills(root);
420
+ let currentTaskText = '';
421
+ try {
422
+ const rows = readProgressRows(root);
423
+ const ip = rows.find(r => r.status === 'in-progress');
424
+ if (ip) currentTaskText = (ip.title || '') + ' ' + (ip.notes || '');
425
+ } catch {}
426
+ const matched = currentTaskText ? _matchAPISkills(root, currentTaskText) : [];
427
+ jsonResult.apiSkills = {
428
+ total: allSkills.length,
429
+ matched: matched.length,
430
+ matchedIds: matched.slice(0, 5).map(s => s.id),
431
+ ids: allSkills.slice(0, 10).map(s => s.id)
432
+ };
433
+ } catch {}
434
+ // 1.9.264: shellGuard 통합 (session close JSON 12번째 통합 필드) — UR-0020 셸 실패 메모리 + 환경 변동
435
+ try {
436
+ const sf = _loadShellFailures(root);
437
+ const drift = _shellEnvDrift(root);
438
+ jsonResult.shellGuard = {
439
+ failureCount: sf.failures.length,
440
+ recent: sf.failures.slice(-3).map(f => ({ cmd: (f.cmd || '').slice(0, 50), exitCode: f.exitCode, shell: f.shell, rules: f.issues || [] })),
441
+ envDriftChanges: drift && drift.changes ? drift.changes.length : 0,
442
+ envDrift: drift ? drift.changes : null
443
+ };
444
+ } catch {}
445
+ } catch {}
446
+ try {
447
+ // 1.9.209: pre-wake-audit 자동 실행 + 저장 (sleep 전 자동 점검)
448
+ if (!opts.noPreWake && !has('--no-pre-wake')) {
449
+ const audit = _runPreWakeAudit(root);
450
+ _saveAndAppendPreWakeReport(root, audit);
451
+ jsonResult.preWakeAudit = {
452
+ auditedAt: audit.auditedAt,
453
+ critical: audit.summary.criticalCount,
454
+ warning: audit.summary.warningCount,
455
+ info: audit.summary.infoCount,
456
+ needsAttention: audit.summary.needsAttention
457
+ };
458
+ }
459
+ } catch {}
460
+ try {
461
+ // 1.9.212: 멱등성 검사 자동 실행 (rule/task/user-requests/wakeups 4영역)
462
+ const idemp = _runIdempotencyAudit(root);
463
+ jsonResult.idempotencyAudit = {
464
+ violations: idemp.summary.totalViolations,
465
+ high: idemp.summary.highSeverity,
466
+ medium: idemp.summary.mediumSeverity,
467
+ low: idemp.summary.lowSeverity,
468
+ verified: idemp.summary.verifiedAreas,
469
+ overall: idemp.summary.overall
470
+ };
471
+ } catch {}
472
+ try {
473
+ // 1.9.221: abnormalShutdown 자동 감지 (1.9.220 통합) — session close 시 다음 재개 가이드 회수
474
+ const ad = _detectAbnormalShutdown(root);
475
+ jsonResult.abnormalShutdown = {
476
+ detected: ad.abnormalShutdown,
477
+ severity: ad.severity,
478
+ signalCount: ad.signals.length,
479
+ signals: ad.signals.map(s => ({ kind: s.kind, severity: s.severity, detail: s.detail })),
480
+ resumeGuide: ad.resumeGuide
481
+ };
482
+ } catch {}
483
+
484
+ process.stdout.write(JSON.stringify(jsonResult, null, 2) + '\n');
485
+ } else {
486
+ // 1.9.217: human 출력 모드에서도 통합 보고 노출 (마감 직전)
487
+ try {
488
+ const isTty = process.stdout.isTTY;
489
+ const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
490
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
491
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
492
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
493
+
494
+ log('');
495
+ log(t(`## 🔚 session close 자동 통합 보고 (1.9.217)`, `## 🔚 session close integrated report`));
496
+ // 1.9.207 + 1.9.223 (delivered 패턴 자동 권장) + 1.9.224 (--auto-apply-delivered 옵션)
497
+ try {
498
+ const reqAudit = _auditUserRequests(root);
499
+ const missCnt = reqAudit.missing ? reqAudit.missing.length : 0;
500
+ let delivered = { candidates: [] };
501
+ try { delivered = _detectDeliveredRequests(root); } catch {}
502
+ if (delivered.candidates && delivered.candidates.length > 0) {
503
+ if (has('--auto-apply-delivered')) {
504
+ // 1.9.224: 자동 정리 (마감 시 호출 — 안전: 패턴 매칭 + 버전 가드)
505
+ let ok = 0;
506
+ for (const c of delivered.candidates) {
507
+ const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'session-close-auto-apply-1.9.224' });
508
+ if (u) ok++;
509
+ }
510
+ log(grn(t(` ✓ delivered 패턴 ${ok}건 자동 완료 (--auto-apply-delivered 1.9.224)`, ` ✓ ${ok} delivered pattern(s) auto-completed (--auto-apply-delivered)`)));
511
+ } else {
512
+ log(yel(t(` 📥 delivered 패턴 ${delivered.candidates.length}건 (1.9.223) — 자동 완료 가능`, ` 📥 ${delivered.candidates.length} delivered pattern(s) — auto-completable`)));
513
+ log(dim(t(` → leerness requests auto-complete --apply (수동) 또는 session close --auto-apply-delivered (1.9.224)`, ` → leerness requests auto-complete --apply (manual) or session close --auto-apply-delivered`)));
514
+ }
515
+ } else if (missCnt > 0) {
516
+ log(red(t(` ⚠ 미답 사용자 요청 ${missCnt}건 (task-log/plan/decisions 매칭 안 됨)`, ` ⚠ ${missCnt} unanswered user request(s) (no task-log/plan/decisions match)`)));
517
+ } else if (reqAudit.open > 0) {
518
+ log(grn(t(` ✓ 사용자 요청 ${reqAudit.open}건 모두 tracked`, ` ✓ all ${reqAudit.open} user request(s) tracked`)));
519
+ } else {
520
+ log(dim(t(` ℹ 사용자 요청 없음 (UR 백로그 비어있음)`, ` ℹ no user requests (UR backlog empty)`)));
521
+ }
522
+ } catch {}
523
+ // 1.9.209
524
+ try {
525
+ if (!opts.noPreWake && !has('--no-pre-wake')) {
526
+ const audit = _runPreWakeAudit(root);
527
+ _saveAndAppendPreWakeReport(root, audit);
528
+ const sum = audit.summary;
529
+ if (sum.criticalCount > 0) {
530
+ log(red(t(` 🚨 pre-wake-audit: critical ${sum.criticalCount} (다음 깨어남 시 점검 필요)`, ` 🚨 pre-wake-audit: critical ${sum.criticalCount} (check before next wake)`)));
531
+ } else if (sum.warningCount > 0) {
532
+ log(yel(` ⚠ pre-wake-audit: warning ${sum.warningCount}`));
533
+ } else {
534
+ log(grn(t(` ✓ pre-wake-audit: clean (sleep 안전)`, ` ✓ pre-wake-audit: clean (safe to sleep)`)));
535
+ }
536
+ }
537
+ } catch {}
538
+ // 1.9.212
539
+ try {
540
+ const idemp = _runIdempotencyAudit(root);
541
+ const v = idemp.summary.totalViolations;
542
+ if (v > 0) {
543
+ log(red(t(` ⚠ 멱등성 위반 ${v}건 (high: ${idemp.summary.highSeverity})`, ` ⚠ ${v} idempotency violation(s) (high: ${idemp.summary.highSeverity})`)));
544
+ log(dim(t(` → leerness idempotency audit 으로 상세 확인`, ` → see details with leerness idempotency audit`)));
545
+ } else {
546
+ log(grn(t(` ✓ 멱등성 검사 통과 — verified ${idemp.summary.verifiedAreas} 영역`, ` ✓ idempotency check passed — ${idemp.summary.verifiedAreas} area(s) verified`)));
547
+ }
548
+ } catch {}
549
+ // 1.9.264: 셸 실패 메모리 + 환경 변동 요약 (UR-0020) — 마감 시 이번 세션 셸 실패를 회고에 노출
550
+ try {
551
+ const sf = _loadShellFailures(root);
552
+ const drift = _shellEnvDrift(root);
553
+ const driftN = drift && drift.changes ? drift.changes.length : 0;
554
+ if (sf.failures.length > 0 || driftN > 0) {
555
+ if (driftN > 0) log(yel(t(` ⚠ 환경 버전 변동 ${driftN}건 — 다음 세션 셸 실패 기록 재검토 권장`, ` ⚠ ${driftN} environment version change(s) — review shell-failure log next session`)));
556
+ if (sf.failures.length > 0) {
557
+ log(yel(t(` 🐚 셸 실패 누적 ${sf.failures.length}건 — 다음 handoff 가 자동 노출`, ` 🐚 ${sf.failures.length} accumulated shell failure(s) — surfaced by next handoff`)));
558
+ log(dim(t(` → 명령 실행 전 점검: leerness shell-guard "<command>"`, ` → check before running: leerness shell-guard "<command>"`)));
559
+ }
560
+ } else {
561
+ log(grn(t(` ✓ 셸 실패 기록 없음 (터미널 호환성 양호)`, ` ✓ no shell-failure records (terminal compatibility OK)`)));
562
+ }
563
+ } catch {}
564
+ // 1.9.237: session close --auto-cleanup-branches — 50+ release/* branches 시 자동 정리
565
+ // 1.9.224 패턴 (--auto-apply-delivered) 확장 — 마감 시 운영 누적 폐기물 자동 정리
566
+ // 안전: keep 10, merged 만, 현재 branch 보호
567
+ try {
568
+ const branchR = cp.spawnSync('git', ['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
569
+ if (branchR.status === 0) {
570
+ const merged = (branchR.stdout || '').split('\n')
571
+ .map(l => l.replace(/^\*?\s+/, '').trim())
572
+ .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
573
+ if (merged.length > 50) {
574
+ if (has('--auto-cleanup-branches')) {
575
+ merged.sort((a, b) => {
576
+ const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
577
+ const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
578
+ for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
579
+ return 0;
580
+ });
581
+ const curR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
582
+ const cur = (curR.stdout || '').trim();
583
+ const toDelete = merged.slice(10).filter(b => b !== cur);
584
+ let okCnt = 0;
585
+ for (const b of toDelete) {
586
+ const r = cp.spawnSync('git', ['branch', '-d', b], { cwd: root, encoding: 'utf8' });
587
+ if (r.status === 0) okCnt++;
588
+ }
589
+ log(grn(t(` ✓ release 정리 ${okCnt}/${toDelete.length}건 (--auto-cleanup-branches 1.9.237, keep 10)`, ` ✓ release cleanup ${okCnt}/${toDelete.length} (--auto-cleanup-branches, keep 10)`)));
590
+ } else {
591
+ log(yel(t(` 🗑 release/* merged ${merged.length}개 (50+) — cleanup 가능 (1.9.235)`, ` 🗑 ${merged.length} merged release/* branches (50+) — cleanup available`)));
592
+ log(dim(t(` → leerness release cleanup --apply --keep 10 (수동)`, ` → leerness release cleanup --apply --keep 10 (manual)`)));
593
+ log(dim(t(` → 또는 session close --auto-cleanup-branches (1.9.237 자동)`, ` → or session close --auto-cleanup-branches (auto)`)));
594
+ }
595
+ }
596
+ }
597
+ } catch {}
598
+ // 1.9.243: session close --auto-fix-encoding — 셸 스크립트 인코딩 위험 자동 회복 (UR-0014 3단계)
599
+ // 1.9.224 (--auto-apply-delivered) / 1.9.237 (--auto-cleanup-branches) 패턴 확장
600
+ // 마감 시 한국어/일본어/중국어 PowerShell 인코딩 위험 자동 BOM 추가
601
+ try {
602
+ const encScan = _scanShellScriptsEncoding(root);
603
+ if (encScan.atRisk && encScan.atRisk.length > 0) {
604
+ if (has('--auto-fix-encoding')) {
605
+ let ok = 0;
606
+ for (const r of encScan.atRisk) {
607
+ try {
608
+ const fullPath = path.join(root, r.file);
609
+ const orig = fs.readFileSync(fullPath);
610
+ const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
611
+ const fixed = Buffer.concat([bom, orig]);
612
+ fs.writeFileSync(fullPath, fixed);
613
+ ok++;
614
+ } catch {}
615
+ }
616
+ log(grn(t(` ✓ 인코딩 위험 ${ok}/${encScan.atRisk.length}건 UTF-8 BOM 자동 추가 (--auto-fix-encoding 1.9.243)`, ` ✓ ${ok}/${encScan.atRisk.length} encoding risk(s) auto-fixed with UTF-8 BOM (--auto-fix-encoding)`)));
617
+ } else {
618
+ log(yel(t(` ⚠ 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 (1.9.241) — 자동 회복 가능`, ` ⚠ ${encScan.atRisk.length} shell-script encoding risk(s) — auto-fixable`)));
619
+ log(dim(t(` → leerness env encoding --apply (수동) 또는 session close --auto-fix-encoding (1.9.243 자동)`, ` → leerness env encoding --apply (manual) or session close --auto-fix-encoding (auto)`)));
620
+ }
621
+ }
622
+ } catch {}
623
+ // 1.9.232: 마감 시 pulse 한 줄 자동 노출 — 다음 라운드 진입 시 즉시 상태 인지
624
+ try {
625
+ const rh = _computeRoundHistory(root);
626
+ const ms = _computeMilestones(root);
627
+ const rows = readProgressRows(root);
628
+ const tIn = rows.filter(r => r.status === 'in-progress').length;
629
+ const dCnt = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
630
+ const rActive = readRules(root).filter(r => r.status === 'active').length;
631
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
632
+ const pCnt = (planText.match(/^### M-\d{4,}\./gm) || []).length;
633
+ const lCnt = _loadLessons(root).length;
634
+ const mem = `T${tIn}/D${dCnt}/R${rActive}/P${pCnt}/L${lCnt}`;
635
+ let pulseLine = `📍 v${VERSION} · 🔄 R${rh.roundCount} · 🧠 ${mem}`;
636
+ if (ms.next) {
637
+ const eta = ms.next.etaDays != null ? ` (${ms.next.etaDays}d)` : '';
638
+ pulseLine += ` · 🎯 R${ms.next.milestone}${eta}`;
639
+ }
640
+ log('');
641
+ log(` ${pulseLine} ${dim('— leerness pulse (1.9.232)')}`);
642
+ } catch {}
643
+ } catch {}
644
+ }
645
+ }
646
+
647
+ module.exports = { sessionClose };