leerness 1.36.78 → 1.36.80

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.
@@ -0,0 +1,254 @@
1
+ // lib/preview-serve.js — 라이브 미리보기 (1.36.80, 사용자 요청 UR-0067).
2
+ // 문제: 시안(preview mockup)이 "파일로만" 존재해 사용자가 직접 열어야 했다. 요청: 임시 워크스페이스에 프로젝트에 맞춰
3
+ // 생성하고 로컬 서버로 띄워 확인한 뒤, 확인이 끝나면 임시 워크스페이스를 정리.
4
+ // 모드(사용자 지시): A=self(기본) 자립형 HTML 을 0-deps node http 로 제공 — 어떤 스택에서도 즉시 동작.
5
+ // B=project(옵트인) 프로젝트의 정적 디렉토리에 시안을 배치해 **프로젝트 자신의 dev 서버**로 확인.
6
+ // 미설정이면 강제하지 않고 A 로 진행하되 선택 질문을 출력한다(false-PASS 편향 — 흐름을 막지 않는다).
7
+ // leerness 는 사용자의 dev 서버를 임의로 실행하지 않는다(사용자 코드 실행은 명시 옵트인 영역).
8
+ 'use strict';
9
+ const fs = require('fs');
10
+ const http = require('http');
11
+ const path = require('path');
12
+ const { absRoot, exists, read, writeUtf8, mkdirp, log, ok, warn, failJson, now } = require('./io');
13
+
14
+ function _cfgPath(root) { return path.join(absRoot(root), '.harness', 'preview-config.json'); }
15
+
16
+ function loadPreviewConfig(root) {
17
+ const f = _cfgPath(root);
18
+ if (!exists(f)) return { mode: null, devUrl: '', staticDir: '' };
19
+ try {
20
+ const j = JSON.parse(read(f));
21
+ const mode = (j && (j.mode === 'self' || j.mode === 'project')) ? j.mode : null;
22
+ return { mode, devUrl: (j && typeof j.devUrl === 'string') ? j.devUrl : '', staticDir: (j && typeof j.staticDir === 'string') ? j.staticDir : '' };
23
+ } catch { return { mode: null, devUrl: '', staticDir: '', corrupt: true }; }
24
+ }
25
+
26
+ function savePreviewConfig(root, cfg, json) {
27
+ const f = _cfgPath(root);
28
+ if (exists(f)) { try { JSON.parse(read(f)); } catch { failJson(json, 'store_corrupt', `preview-config.json 손상 — 덮어쓰기 거부: ${f}`); return false; } }
29
+ mkdirp(path.dirname(f));
30
+ writeUtf8(f, JSON.stringify({ ...cfg, updatedAt: now() }, null, 2) + '\n');
31
+ return true;
32
+ }
33
+
34
+ // 모드 미설정 시 사용자에게 물을 질문 — 강제하지 않고 기본(A)로 진행하며 선택지를 제시한다.
35
+ function modeQuestionLines(root) {
36
+ return [
37
+ ' ❓ 미리보기 모드가 아직 정해지지 않았습니다 — 기본값 A(자립형)로 진행합니다. 사용자에게 확인하세요:',
38
+ ' (A) self — 자립형 HTML 을 leerness 로컬 서버로 확인 (0 의존성 · 어떤 스택이든 즉시 · 오프라인)',
39
+ ' (B) project — 프로젝트 정적 디렉토리에 배치해 프로젝트 자신의 dev 서버로 확인 (스택 정합 ↑ · dev 서버는 사용자가 실행)',
40
+ ' → 설정: leerness preview mode self | leerness preview mode project --static-dir public --dev-url http://localhost:5173',
41
+ ];
42
+ }
43
+
44
+ // 임시 워크스페이스 — 확인이 끝나면(승인/수정요구) 정리된다. 시안 원본(.harness/previews/<id>-mockup.html)은 건드리지 않는다.
45
+ function serveDir(root, id) { return path.join(absRoot(root), '.harness', 'previews', `.serve-${id}`); }
46
+
47
+ function cleanupServeDir(root, id) {
48
+ let cleaned = false;
49
+ const d = serveDir(root, id);
50
+ try { if (exists(d)) { fs.rmSync(d, { recursive: true, force: true }); cleaned = true; } } catch {}
51
+ // (검수 #8) project 모드로 프로젝트 정적 디렉토리에 배치한 사본도 함께 정리 — "확인 끝나면 정리한다"는 안내와 실제를 일치시킨다.
52
+ try {
53
+ const cfgF = _cfgPath(root);
54
+ if (exists(cfgF)) {
55
+ const j = JSON.parse(read(cfgF));
56
+ const placed = (j && j.placed && j.placed[id]) ? j.placed[id] : null;
57
+ if (placed) {
58
+ const abs = path.resolve(absRoot(root), placed);
59
+ const rel = path.relative(absRoot(root), abs);
60
+ if (!rel.startsWith('..') && !path.isAbsolute(rel) && exists(abs)) { fs.rmSync(abs, { force: true }); cleaned = true; }
61
+ delete j.placed[id];
62
+ writeUtf8(cfgF, JSON.stringify(j, null, 2) + '\n');
63
+ }
64
+ }
65
+ } catch {}
66
+ return cleaned;
67
+ }
68
+
69
+ // 프로젝트의 디자인 토큰(있으면)을 임시 워크스페이스로 복사 — 시안이 프로젝트 톤과 어긋나지 않게.
70
+ // 빌드가 필요한 산출물은 복사하지 않는다(leerness 는 사용자 프로젝트를 빌드하지 않는다).
71
+ const _TOKEN_CANDIDATES = ['.harness/design-system.md', 'src/styles/tokens.css', 'src/styles/variables.css', 'styles/tokens.css', 'src/index.css', 'src/app.css', 'app/globals.css'];
72
+ function _collectTokens(root) {
73
+ const found = [];
74
+ for (const rel of _TOKEN_CANDIDATES) {
75
+ const p = path.join(root, rel);
76
+ try { if (exists(p) && fs.statSync(p).isFile() && fs.statSync(p).size <= 256 * 1024) found.push({ rel, text: read(p) }); } catch {}
77
+ if (found.length >= 2) break;
78
+ }
79
+ return found;
80
+ }
81
+
82
+ function buildServeWorkspace(root, p) {
83
+ const srcRel = p.mockupPath || path.join('.harness', 'previews', `${p.id}-mockup.html`);
84
+ const src = path.isAbsolute(srcRel) ? srcRel : path.join(root, srcRel);
85
+ // (검수 #12) 검증 실패 시 빈 .serve-* 디렉토리가 남던 것 — 검증을 먼저, 생성은 그 뒤에.
86
+ if (!exists(src)) return { ok: false, error: `시안 파일 없음: ${srcRel} — 먼저 leerness preview mockup ${p.id}` };
87
+ const dir = serveDir(root, p.id);
88
+ mkdirp(dir);
89
+ let html = read(src);
90
+ const tokens = _collectTokens(root);
91
+ if (tokens.length) {
92
+ // (검수 #9) 프로젝트 CSS 를 <style> 안에 그대로 넣으면 CSS 안의 `</style><script>` 가 탈출해 스크립트가 실행됐다.
93
+ // → 별도 스타일시트 파일로 쓰고 <link> 로 연결(문자열 탈출 자체가 불가능). 외부 리소스 0 은 그대로 유지(같은 워크스페이스).
94
+ const css = tokens.filter(t => /\.css$/.test(t.rel)).map(t => `/* from ${t.rel} */\n${t.text}`).join('\n');
95
+ const noteRels = tokens.map(t => t.rel).join(', ');
96
+ let inject = '';
97
+ if (css) { writeUtf8(path.join(dir, 'project-tokens.css'), css); inject += `<link rel="stylesheet" href="project-tokens.css">\n`; }
98
+ inject += `<!-- leerness: project design context — ${_escComment(noteRels)} -->\n`;
99
+ html = html.replace('</head>', inject + '</head>');
100
+ }
101
+ writeUtf8(path.join(dir, 'index.html'), html);
102
+ return { ok: true, dir, tokens: tokens.map(t => t.rel) };
103
+ }
104
+ function _escComment(s) { return String(s).replace(/--+/g, '-').replace(/[<>]/g, ''); }
105
+
106
+ const _MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.json': 'application/json; charset=utf-8' };
107
+
108
+ // 1.36.80 (사용자 지시): leerness 시그니처 포트 — 전화 키패드의 "LEER"(5-3-3-7). ollama:11434 처럼 기억 가능한 고정값.
109
+ // 이미 사용 중이면 뒤로 순차 탐색(최대 20) → 그래도 없으면 OS 임의 빈 포트(0). 어떤 경우에도 "포트 충돌로 실패"하지 않는다.
110
+ const LEERNESS_PREVIEW_PORT = 5337;
111
+ function _listenWithFallback(server, preferred, host, onReady, onFail) {
112
+ const tries = [];
113
+ for (let i = 0; i < 20; i++) tries.push(preferred + i);
114
+ tries.push(0); // 마지막 수단: OS 가 비어 있는 포트를 준다
115
+ let idx = 0;
116
+ const attempt = () => {
117
+ const port = tries[idx];
118
+ const onErr = (e) => {
119
+ server.removeListener('error', onErr);
120
+ if ((e && (e.code === 'EADDRINUSE' || e.code === 'EACCES')) && idx < tries.length - 1) { idx++; setImmediate(attempt); return; }
121
+ onFail(e, port);
122
+ };
123
+ server.once('error', onErr);
124
+ // 성공 시 실제 바인딩된 포트로 판단한다 — `port !== preferred` 는 port 0(OS 할당) 경로에서도 어긋나고,
125
+ // 무엇보다 "선호 포트가 막혀 옮겨갔다"는 사실은 실제 주소로만 정확히 알 수 있다(실측: 5338 인데 false 보고).
126
+ server.listen(port, host, () => { server.removeListener('error', onErr); const a = server.address().port; onReady(a, preferred > 0 && a !== preferred); });
127
+ };
128
+ attempt();
129
+ }
130
+
131
+ // `leerness preview serve <P-ID> [--port N] [--timeout SEC] [--keep]`
132
+ function previewServeCmd(root, id, deps = {}) {
133
+ const { has, arg, previews } = deps;
134
+ root = absRoot(root);
135
+ const json = !!(has && has('--json'));
136
+ const p = (previews || []).find(x => x.id === id);
137
+ if (!p) { failJson(json, 'not_found', `preview 없음: ${id}`); return; }
138
+ const cfg = loadPreviewConfig(root);
139
+ const mode = cfg.mode || 'self';
140
+
141
+ if (mode === 'project') {
142
+ // B(옵트인): 프로젝트 정적 디렉토리에 배치 — dev 서버 실행은 사용자 몫(사용자 코드를 leerness 가 임의 실행하지 않음)
143
+ const staticDir = (arg ? arg('--static-dir', '') : '') || cfg.staticDir || 'public';
144
+ const destDir = path.resolve(root, staticDir);
145
+ const rel = path.relative(root, destDir);
146
+ if (rel.startsWith('..') || path.isAbsolute(rel)) { failJson(json, 'static_dir_outside_root', `--static-dir 은 프로젝트 안이어야 합니다: ${destDir}`); return; }
147
+ // (검수 #5) 렉시컬 검사만으론 정션/심볼릭 링크로 프로젝트 밖에 쓰는 것을 못 막는다 — 실제 경로로 재확인.
148
+ try {
149
+ const _rootReal2 = fs.realpathSync(root);
150
+ let probe = destDir;
151
+ while (!exists(probe) && path.dirname(probe) !== probe) probe = path.dirname(probe); // 존재하는 최근접 조상
152
+ const realProbe = fs.realpathSync(probe);
153
+ const relReal2 = path.relative(_rootReal2, realProbe);
154
+ if (relReal2.startsWith('..') || path.isAbsolute(relReal2)) { failJson(json, 'static_dir_outside_root', `--static-dir 이 링크를 통해 프로젝트 밖을 가리킵니다: ${destDir} → ${realProbe}`); return; }
155
+ } catch {}
156
+ const built = buildServeWorkspace(root, p);
157
+ if (!built.ok) { failJson(json, 'mockup_missing', built.error); return; }
158
+ mkdirp(destDir);
159
+ const destFile = path.join(destDir, `leerness-preview-${p.id}.html`);
160
+ writeUtf8(destFile, read(path.join(built.dir, 'index.html')));
161
+ // (검수 #8) 배치 위치를 기록해 승인/수정 시 정확히 회수한다(추측 삭제 금지 — 기록된 경로만 지운다).
162
+ try {
163
+ const cur = exists(_cfgPath(root)) ? JSON.parse(read(_cfgPath(root))) : {};
164
+ cur.placed = cur.placed && typeof cur.placed === 'object' ? cur.placed : {};
165
+ cur.placed[p.id] = path.relative(root, destFile).replace(/\\/g, '/');
166
+ writeUtf8(_cfgPath(root), JSON.stringify(cur, null, 2) + '\n');
167
+ } catch {}
168
+ const urlBase = (arg ? arg('--dev-url', '') : '') || cfg.devUrl || 'http://localhost:3000';
169
+ const url = `${String(urlBase).replace(/\/$/, '')}/leerness-preview-${p.id}.html`;
170
+ const relFile = path.relative(root, destFile).replace(/\\/g, '/');
171
+ if (json) { log(JSON.stringify({ ok: true, mode: 'project', id: p.id, file: relFile, url, note: 'dev 서버는 사용자가 실행합니다' }, null, 2)); return; }
172
+ ok(`시안 배치(project 모드): ${relFile}`);
173
+ log(` → 프로젝트 dev 서버를 실행한 뒤 열기: ${url}`);
174
+ log(` ⓘ leerness 는 사용자의 dev 서버를 자동 실행하지 않습니다. 확인이 끝나면: leerness preview approve ${p.id} (배치본도 함께 정리)`);
175
+ return;
176
+ }
177
+
178
+ // A(기본): 자립형 HTML 을 0-deps 로컬 서버로
179
+ const built = buildServeWorkspace(root, p);
180
+ if (!built.ok) { failJson(json, 'mockup_missing', built.error); return; }
181
+ const _portArg = (arg ? arg('--port', '') : '');
182
+ const port = (_portArg && _portArg !== true) ? Math.max(0, parseInt(_portArg, 10) || 0) : LEERNESS_PREVIEW_PORT;
183
+ const timeoutSec = Math.max(5, parseInt((arg ? arg('--timeout', '') : '') || '600', 10) || 600);
184
+ const _rootReal = (() => { try { return fs.realpathSync(built.dir); } catch { return path.resolve(built.dir); } })();
185
+ const server = http.createServer((req, res) => {
186
+ // (검수 #4) ① 잘못된 인코딩(%E0%A4%A)이 URIError 로 서버를 죽였다 → 400. ② startsWith 는 형제 디렉토리
187
+ // (.serve-P-0001-secret)를 통과시켰다 → path.relative 로 정확 판정. ③ 정션/심볼릭 링크로 밖을 가리키면
188
+ // 렉시컬 검사만으론 못 막는다 → realpath 로 실제 위치까지 확인.
189
+ let rel;
190
+ try { rel = decodeURIComponent(String(req.url || '/').split('?')[0]); }
191
+ catch { res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }); res.end('bad request'); return; }
192
+ if (rel === '/' || rel === '') rel = '/index.html';
193
+ const fp = path.resolve(built.dir, '.' + rel);
194
+ const relCheck = path.relative(_rootReal, fp);
195
+ if (relCheck.startsWith('..') || path.isAbsolute(relCheck)) { res.writeHead(403); res.end('forbidden'); return; }
196
+ let realFp = fp;
197
+ try { realFp = fs.realpathSync(fp); } catch { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('not found'); return; }
198
+ const relReal = path.relative(_rootReal, realFp);
199
+ if (relReal.startsWith('..') || path.isAbsolute(relReal)) { res.writeHead(403); res.end('forbidden'); return; }
200
+ fs.readFile(realFp, (err, buf) => { // 실검사를 통과한 실제 경로로 읽는다
201
+ if (err) { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('not found'); return; }
202
+ res.writeHead(200, { 'content-type': _MIME[path.extname(realFp).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
203
+ res.end(buf);
204
+ });
205
+ });
206
+ _listenWithFallback(server, port, '127.0.0.1', (actual, moved) => {
207
+ const url = `http://127.0.0.1:${actual}/`;
208
+ if (json) { log(JSON.stringify({ ok: true, mode: 'self', id: p.id, url, port: actual, preferredPort: port, portFallback: !!moved, timeoutSec, workspace: path.relative(root, built.dir).replace(/\\/g, '/'), tokens: built.tokens }, null, 2)); }
209
+ else {
210
+ ok(`라이브 미리보기 실행 중 — ${url}`);
211
+ if (moved) warn(` ⓘ 기본 포트 ${port} 사용 중 → 비어 있는 ${actual} 로 자동 전환`);
212
+ log(` 시안: ${p.id} 「${p.title}」`);
213
+ if (built.tokens.length) log(` 프로젝트 디자인 컨텍스트 반영: ${built.tokens.join(', ')}`);
214
+ if (!cfg.mode) modeQuestionLines(root).forEach(l => log(l));
215
+ log(` → 사용자에게 이 주소를 열어 확인받고, 승인: leerness preview approve ${p.id} · 수정요구: leerness preview revise ${p.id} --note "..."`);
216
+ log(` ⓘ ${timeoutSec}초 후 자동 종료(Ctrl+C 로 즉시 종료). 확인이 끝나면 임시 워크스페이스는 승인/수정 시 자동 정리됩니다.`);
217
+ }
218
+ const stop = (why) => {
219
+ try { server.close(); } catch {}
220
+ if (!(has && has('--keep'))) cleanupServeDir(root, p.id);
221
+ if (!json) log(` ⏹ 미리보기 종료(${why})${(has && has('--keep')) ? ' — 임시 워크스페이스 보존(--keep)' : ' — 임시 워크스페이스 정리됨'}`);
222
+ process.exit(0);
223
+ };
224
+ const t = setTimeout(() => stop('timeout'), timeoutSec * 1000);
225
+ if (t.unref) t.unref();
226
+ process.on('SIGINT', () => stop('SIGINT'));
227
+ process.on('SIGTERM', () => stop('SIGTERM'));
228
+ }, (e, tried) => { failJson(json, 'serve_failed', `로컬 서버 시작 실패: ${e && e.message} (마지막 시도 포트 ${tried})`); });
229
+ }
230
+
231
+ // `leerness preview mode [self|project] [--static-dir public] [--dev-url http://localhost:5173]`
232
+ function previewModeCmd(root, val, deps = {}) {
233
+ const { has, arg } = deps;
234
+ root = absRoot(root);
235
+ const json = !!(has && has('--json'));
236
+ const cfg = loadPreviewConfig(root);
237
+ if (!val) {
238
+ if (json) { log(JSON.stringify({ ok: true, mode: cfg.mode, effective: cfg.mode || 'self', devUrl: cfg.devUrl, staticDir: cfg.staticDir, corrupt: cfg.corrupt || undefined }, null, 2)); return; }
239
+ log(`# leerness preview mode — ${cfg.mode ? cfg.mode : '(미설정 → 기본 self)'}`);
240
+ if (cfg.staticDir) log(` static-dir: ${cfg.staticDir}`);
241
+ if (cfg.devUrl) log(` dev-url: ${cfg.devUrl}`);
242
+ if (!cfg.mode) modeQuestionLines(root).forEach(l => log(l));
243
+ return;
244
+ }
245
+ if (val !== 'self' && val !== 'project') { failJson(json, 'invalid_mode', `preview mode <self|project> — 받은 값: ${val}`); return; }
246
+ const next = { mode: val, devUrl: (arg ? (arg('--dev-url', '') || cfg.devUrl) : cfg.devUrl) || '', staticDir: (arg ? (arg('--static-dir', '') || cfg.staticDir) : cfg.staticDir) || '' };
247
+ if (next.devUrl === true) next.devUrl = ''; if (next.staticDir === true) next.staticDir = '';
248
+ if (!savePreviewConfig(root, next, json)) return;
249
+ if (json) { log(JSON.stringify({ ok: true, ...next }, null, 2)); return; }
250
+ ok(`preview mode = ${val}`);
251
+ if (val === 'project') log(` static-dir: ${next.staticDir || 'public'} · dev-url: ${next.devUrl || 'http://localhost:3000'} (dev 서버는 사용자가 실행)`);
252
+ }
253
+
254
+ module.exports = { LEERNESS_PREVIEW_PORT, previewServeCmd, previewModeCmd, loadPreviewConfig, savePreviewConfig, cleanupServeDir, serveDir, modeQuestionLines, buildServeWorkspace };
package/lib/pure-utils.js CHANGED
@@ -831,8 +831,17 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
831
831
  function _parseSkillsValue(v, catalog) {
832
832
  if (!v || v === true) return [];
833
833
  if (v === 'all') return Object.keys(catalog || {});
834
- if (v === 'recommended') return ['office', 'commerce-api', 'ai-verified-skill-publisher', 'feature-implementation', 'project-roadmap-generator'];
835
- return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => (catalog || {})[s]);
834
+ // 1.36.80 (사용자 지시): 내장 기본에서 작성자 개인 도메인 스킬(커머스/GA4/앱스토어/오피스/크롤링/파이어베이스/퍼블리셔) 제거 —
835
+ // 검증 근거 없이 verification:'passed' 주장했고 내용은 설명 4줄짜리 메타데이터뿐이라 전역 배포본의 기본값으로 부적절.
836
+ // 남는 2종은 leerness 자체 워크플로(기능 구현 계약·로드맵 산출)라 도구와 함께 유지된다.
837
+ if (v === 'recommended') return Object.keys(catalog || {});
838
+ // 1.36.80 (검수 #10): 명시 요청한 미지/제거된 id 를 조용히 걸러 exit 0 + 0개 설치로 끝나던 것 —
839
+ // 호출부가 unknown 을 보고 분명히 거절할 수 있도록 함께 반환한다(하위호환: 반환은 여전히 배열).
840
+ const req = String(v).split(',').map(s => s.trim()).filter(Boolean);
841
+ const known = req.filter(s => (catalog || {})[s]);
842
+ const unknown = req.filter(s => !(catalog || {})[s]);
843
+ if (unknown.length) { try { Object.defineProperty(known, 'unknown', { value: unknown, enumerable: false }); } catch {} }
844
+ return known;
836
845
  }
837
846
 
838
847
  // 1.9.370 (UR-0025): memory archive 블록 파서 순수 코어 — "## 제거 DATE (target: \"...\")" 블록 → {date,target,originalHeader}[].
package/lib/referee.js ADDED
Binary file
@@ -212,7 +212,35 @@ function reviewRequestCmd(root, request, deps = {}) {
212
212
  if (broadHits.length) efficiencyHints.push(`🤔 범위 과대 신호(${broadHits.slice(0, 3).join(', ')}) — 더 작게 쪼갤 수 있나? 요청에 명시된 것만 (Karpathy 단순성)`);
213
213
  if (specHits.length) efficiencyHints.push(`🧪 투기적 신호(${specHits.slice(0, 3).join(', ')}) — 지금 필요한 범위만, 추측성 일반화 보류 (Karpathy 단순성)`);
214
214
 
215
- // 7) proceed 권장 (충돌 critical false)
215
+ // 6.9) 1.36.80 (P-0003, ponytail 검토 채택 · codex 공동판단 ADOPT): **순서 있는 최소성 결정 영수증** — 옵트인(--ladder).
216
+ // 왜: 같은 질문들이 review-request(내부 재사용) · reuse-check(외부 OSS) · lens(완료 시점)로 흩어져 있고,
217
+ // 특히 reuse-check 는 5번째 단(OSS 도입)만 물어 구조적으로 "의존성 추가" 쪽으로 밀었다. 순서를 복원한다.
218
+ // 원칙(공동 합의): 자문 전용(차단·점수·임계 없음) · 근거 없으면 unknown(not-applicable 아님) ·
219
+ // "한 줄"은 목표가 아니라 질문 · 보존 요구사항(검증/보안/접근성/관측)이 가장 싼 단을 이길 수 있음 ·
220
+ // 저장하지 않는다(결정으로 남길 가치가 있으면 사용자가 leerness decision add 로 명시 기록).
221
+ let ladder = null;
222
+ if (has && has('--ladder')) {
223
+ const _rung = (id, question, status, evidence) => ({ id, question, status, evidence: evidence || '' });
224
+ ladder = {
225
+ // (검수 #11) "아무것도 저장하지 않는다"는 과장이었다 — 사다리 **내용**은 저장하지 않지만 명령 실행 기록(run/usage)은
226
+ // 다른 명령과 동일하게 남는다. 주장 범위를 사실에 맞춘다.
227
+ note: 'advisory — 근거 없는 칸은 unknown 입니다. 순서는 비용 순서일 뿐 정답 순서가 아닙니다(보안/접근성은 더 비싼 단이 옳을 수 있음). 사다리 내용은 저장되지 않습니다(명령 실행 기록은 다른 명령과 동일하게 남습니다).',
228
+ rungs: [
229
+ _rung('needed', '이 기능이 정말 필요한가? (요청 자체를 줄이거나 없앨 수 있나)', 'unknown', '요청 텍스트만으로는 판정 불가 — 사용자에게 확인'),
230
+ _rung('in-codebase', '이미 이 코드베이스에 있나?', reuseCandidates.length ? 'candidate' : 'unknown',
231
+ reuseCandidates.length ? `reuse 후보 ${reuseCandidates.length}건(토큰 매칭 — 확정 아님): ${reuseCandidates.slice(0, 3).map(r => r.name || r.id || r).join(', ')}` : 'reuse-map 매칭 없음 — 부재 증명은 아님 (leerness reuse find "<capability>")'),
232
+ _rung('stdlib', '표준 라이브러리가 이미 하나?', 'unknown', '런타임/버전/배포 타깃 의존 — 사람이 확인'),
233
+ _rung('native', '플랫폼 네이티브 기능이 있나? (예: <input type="date">)', 'unknown', '타깃 플랫폼·브라우저 지원 범위 확인 필요'),
234
+ _rung('installed', '이미 설치된 의존성으로 되나?', 'unknown', '설치됨 ≠ 적합 — 보안/유지보수/스코프 확인 (leerness reuse-check)'),
235
+ _rung('compose', '기존 것 조합/짧은 코드로 되나?', 'unknown', '"한 줄"은 목표가 아니라 질문 — 검증·에러처리를 숨기지 말 것'),
236
+ _rung('minimum', '위가 모두 아니면: 동작하는 최소 구현은?', 'unknown', '여기까지 왔다면 범위를 명시적으로 적어두세요'),
237
+ ],
238
+ preserved: ['입력 검증(신뢰 경계)', '보안·비밀 취급', '접근성', '에러 처리·관측', '데이터 손실 방지'],
239
+ preservedNote: '위 항목은 "줄이기" 대상이 아니다 — 더 비싼 단을 택해야 할 근거가 된다.',
240
+ };
241
+ }
242
+
243
+ // 7) proceed 권장 (충돌 critical 시 false) — ladder 는 자문이라 이 판정에 영향을 주지 않는다(공동 합의 가드레일)
216
244
  const proceed = conflictHints.length < 3 && planConflicts.length === 0;
217
245
 
218
246
  const dt = Date.now() - t0;
@@ -231,6 +259,7 @@ function reviewRequestCmd(root, request, deps = {}) {
231
259
  constraintSuggestions: constraintsCheck.suggestions,
232
260
  proceed,
233
261
  proceedReason: proceed ? '안전 — 충돌 신호 < 3 + plan 충돌 0' : '⚠ 충돌 critical — 사용자 확인 후 진행',
262
+ ...(ladder ? { ladder } : {}),
234
263
  durationMs: dt
235
264
  };
236
265
 
@@ -289,6 +318,19 @@ function reviewRequestCmd(root, request, deps = {}) {
289
318
  recommendedSteps.forEach(s => log(` ${s}`));
290
319
  log('');
291
320
  }
321
+ // 1.36.80 (P-0003): 최소성 사다리 — 옵트인 자문 영수증. proceed 판정에는 영향 없음.
322
+ if (ladder) {
323
+ log(`## 🪜 최소성 결정 사다리 (--ladder · 자문)`);
324
+ for (const r of ladder.rungs) {
325
+ const mark = r.status === 'confirmed' ? '✅' : (r.status === 'candidate' ? '🔎' : (r.status === 'not-applicable' ? '➖' : '❔'));
326
+ log(` ${mark} [${r.status}] ${r.question}`);
327
+ if (r.evidence) log(` ${r.evidence}`);
328
+ }
329
+ log(` 🔒 줄이면 안 되는 것: ${ladder.preserved.join(' · ')}`);
330
+ log(` ${ladder.preservedNote}`);
331
+ log(` ⓘ ${ladder.note}`);
332
+ log('');
333
+ }
292
334
  log(`## ▶ 진행 권장: ${proceed ? '✓ 진행 안전' : '⚠ 사용자 확인 필요'}`);
293
335
  log(` 사유: ${out.proceedReason}`);
294
336
  log(` 분석 소요: ${dt}ms`);
@@ -58,18 +58,22 @@ function _envKeys(root) {
58
58
 
59
59
  function _shallowExts(root) {
60
60
  const counts = {};
61
+ let _extBudget = 20000; // 1.36.79 (검수 #4): 전체 예산 — 거대 저장소에서도 공정 집계 + 상한 보장
61
62
  const scan = (dir, depth) => {
62
63
  let entries = [];
63
64
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
64
- for (const e of entries.slice(0, 400)) {
65
+ // 1.36.79 (검수 #4): 디렉토리당 400개 컷이 큰 플랫 디렉토리에서 **집계를 편향**시켰다(1501 JS vs 500 PY 인데 Python 1위).
66
+ // 순위가 실제 파일 수에 기반하려면 세는 범위가 공정해야 한다 — 상한을 크게(디렉토리당 5000, 전체 예산 20000) 두고 공정 집계.
67
+ for (const e of entries.slice(0, 5000)) {
68
+ if (_extBudget <= 0) return;
65
69
  if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' || e.name === 'build') continue;
66
70
  const p = path.join(dir, e.name);
67
71
  if (e.isDirectory()) { if (depth > 0) scan(p, depth - 1); continue; }
68
72
  const ext = path.extname(e.name).toLowerCase();
69
- if (EXT_LANG[ext]) counts[EXT_LANG[ext]] = (counts[EXT_LANG[ext]] || 0) + 1;
73
+ if (EXT_LANG[ext]) { counts[EXT_LANG[ext]] = (counts[EXT_LANG[ext]] || 0) + 1; _extBudget--; }
70
74
  }
71
75
  };
72
- scan(absRoot(root), 2);
76
+ scan(absRoot(root), 3); // 1.36.79 (도그푸딩 P1-D): depth 2→3 — src/pkg/mod 3단 레이아웃의 소스가 통째로 안 세어지던 것
73
77
  return counts;
74
78
  }
75
79
 
@@ -84,6 +88,11 @@ function detectTechProfile(root) {
84
88
  }
85
89
  const extCounts = _shallowExts(root);
86
90
  for (const [lang, n] of Object.entries(extCounts)) if (n >= 3) addL(lang, `${n} source files`);
91
+ // 1.36.79 (도그푸딩 P1-D, 실 프로젝트): 마커 파일 존재만으로 순서가 정해져 **devDependency 1개짜리 package.json 이
92
+ // 205개 .py 파일을 이겨** Python 모노레포가 "javascript" 로 라벨링됐다(실측). 실제 소스 파일 수를 1순위 근거로
93
+ // 재정렬 — 파일이 많은 언어가 앞, 동수면 기존(마커) 순서 유지. 마커-only(0 파일)는 뒤로.
94
+ languages.sort((a, b) => (extCounts[b.id] || 0) - (extCounts[a.id] || 0));
95
+ for (const l of languages) { const n = extCounts[l.id] || 0; if (n >= 3 && !/source files/.test(l.evidence)) l.evidence = `${l.evidence} · ${n} source files`; }
87
96
  // deps 수집 — 1.36.54 (#5): 모노레포 대응, 루트 + 얕은 하위(2단계) 매니페스트 병합 (vendor 류 제외)
88
97
  let deps = {};
89
98
  let reqTxt = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.78",
3
+ "version": "1.36.80",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",