leerness 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12810 -12723
- package/README.md +169 -169
- package/bin/leerness.js +19523 -19409
- package/lib/catalogs.js +485 -433
- package/lib/pure-utils.js +1407 -1337
- package/package.json +58 -58
- package/scripts/e2e.js +6204 -6204
package/lib/pure-utils.js
CHANGED
|
@@ -1,1337 +1,1407 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// 1.9.274 (UR-0025 1단계, GPT-5.5 리뷰): bin/harness.js 단일 대형 파일 모듈 분리 — 점진적·비파괴 시작.
|
|
4
|
-
// 여기에는 harness 내부 상태/다른 함수에 의존하지 않는 "순수 함수"만 추출한다 (부작용 0, 단위 테스트 대상).
|
|
5
|
-
// harness.js 는 이 모듈을 require 해 동일 이름으로 사용한다. 동작 동일 — selftest 가 7종 모두 검증.
|
|
6
|
-
|
|
7
|
-
// 보안: 환경변수 키가 시크릿(TOKEN/SECRET/PASSWORD/API_KEY/PRIVATE)인지 판별.
|
|
8
|
-
function _isSecretKey(k) {
|
|
9
|
-
return /TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE/i.test(k);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// semver 비교: a>b → 1, a<b → -1, 같음 → 0. (누락 파트/null 안전)
|
|
13
|
-
function compareVer(a, b) {
|
|
14
|
-
const A = String(a || '0'), B = String(b || '0');
|
|
15
|
-
const sa = A.split('-')[0].split('.').map(n => parseInt(n || '0', 10));
|
|
16
|
-
const sb = B.split('-')[0].split('.').map(n => parseInt(n || '0', 10));
|
|
17
|
-
for (let i = 0; i < 3; i++) {
|
|
18
|
-
const x = sa[i] || 0, y = sb[i] || 0;
|
|
19
|
-
if (x > y) return 1;
|
|
20
|
-
if (x < y) return -1;
|
|
21
|
-
}
|
|
22
|
-
// 1.9.354 (UR-0072 외부리뷰): 숫자 동일 시 pre-release(-beta/-next 등) < 정식 (semver 규칙). 이전: -beta 무시 → 동일 취급.
|
|
23
|
-
const preA = A.includes('-'), preB = B.includes('-');
|
|
24
|
-
if (preA && !preB) return -1;
|
|
25
|
-
if (!preA && preB) return 1;
|
|
26
|
-
return 0;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// harness 버전 문자열 파싱: canonical "1.9.0" / legacy plus "leerness@1.8.0+plus@1.0.1" / "leerness@1.8.0".
|
|
30
|
-
function parseHarnessVersion(text) {
|
|
31
|
-
const t = String(text || '').trim();
|
|
32
|
-
const plus = t.match(/plus@(\d+\.\d+\.\d+)/);
|
|
33
|
-
const baseAt = t.match(/leerness@(\d+\.\d+\.\d+)/);
|
|
34
|
-
const bare = t.match(/^(\d+\.\d+\.\d+)\s*$/);
|
|
35
|
-
return {
|
|
36
|
-
plus: plus ? plus[1] : null,
|
|
37
|
-
base: baseAt ? baseAt[1] : (bare ? bare[1] : null),
|
|
38
|
-
raw: t || '(not installed)'
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// UTF-8 바이트열의 CJK 분류 (한국어/일본어/중국어/기타) — 인코딩 오인식 위험 감지용.
|
|
43
|
-
function _classifyCJK(buf, len) {
|
|
44
|
-
let korean = 0, japanese = 0, chinese = 0, other = 0, han = 0;
|
|
45
|
-
for (let i = 0; i < Math.min(buf.length, len); i++) {
|
|
46
|
-
const b = buf[i];
|
|
47
|
-
if (b < 0x80) continue;
|
|
48
|
-
if (b >= 0xEA && b <= 0xED) korean++;
|
|
49
|
-
else if (b === 0xE3) japanese++; // kana/기호 (U+3000-3FFF) — 일본어 강한 신호
|
|
50
|
-
else if (b >= 0xE4 && b <= 0xE9) han++; // CJK 통합 한자 — 한·중·일 공유라 모호
|
|
51
|
-
else other++;
|
|
52
|
-
}
|
|
53
|
-
// 1.9.354 (UR-0072 외부리뷰): 한자는 한·중·일 공유라 lead byte 만으로 판별 불가 → kana 가 있으면 일본어, 없으면 중국어로 귀속(휴리스틱). advisory 라벨 일본어 오판 완화.
|
|
54
|
-
if (japanese > 0) japanese += han; else chinese += han;
|
|
55
|
-
return { korean, japanese, chinese, other };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// CJK 분류 결과 → 위험 라벨 (Windows 코드페이지 오인식 안내).
|
|
59
|
-
function _riskLabel(cjk) {
|
|
60
|
-
if (cjk.korean >= cjk.japanese && cjk.korean >= cjk.chinese && cjk.korean > 0) {
|
|
61
|
-
return { type: 'korean', risk: 'Windows 한국어 PowerShell 에서 CP949 로 오인식 가능 (BOM 추가 권장)' };
|
|
62
|
-
}
|
|
63
|
-
if (cjk.japanese > cjk.korean && cjk.japanese >= cjk.chinese) {
|
|
64
|
-
return { type: 'japanese', risk: 'Windows 일본어 PowerShell 에서 CP932 (Shift-JIS) 로 오인식 가능 (BOM 추가 권장)' };
|
|
65
|
-
}
|
|
66
|
-
if (cjk.chinese > 0) {
|
|
67
|
-
return { type: 'chinese', risk: 'Windows 중국어 PowerShell 에서 CP936 (GBK) 로 오인식 가능 (BOM 추가 권장)' };
|
|
68
|
-
}
|
|
69
|
-
return { type: 'non-ascii', risk: 'Windows 비-ASCII 셸 스크립트 — BOM 없는 UTF-8 인코딩 오인식 가능 (BOM 추가 권장)' };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// OS 시스템 언어 감지 (UR-0022): POSIX env > Intl ICU locale > null.
|
|
73
|
-
function _detectSystemLang(env) {
|
|
74
|
-
env = env || process.env;
|
|
75
|
-
const raw = String(env.LC_ALL || env.LC_CTYPE || env.LANG || env.LANGUAGE || '').toLowerCase();
|
|
76
|
-
if (raw && raw !== 'c' && raw !== 'posix') {
|
|
77
|
-
if (/(^|[^a-z])ko([_\-.]|$)|korean|[_-]kr([_\-.]|$)/.test(raw)) return 'ko';
|
|
78
|
-
if (/(^|[^a-z])en([_\-.]|$)|english|[_-](us|gb)([_\-.]|$)/.test(raw)) return 'en';
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
const loc = (Intl.DateTimeFormat().resolvedOptions().locale || '').toLowerCase();
|
|
82
|
-
const primary = loc.split('-')[0];
|
|
83
|
-
if (primary === 'ko') return 'ko';
|
|
84
|
-
if (primary === 'en') return 'en';
|
|
85
|
-
} catch {}
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// CLI `--help` 출력에서 슬래시 명령/하위명령 best-effort 파싱 (UR-0021 3단계). 순수 문자열 처리.
|
|
90
|
-
function _parseSlashFromHelp(text, invoke = 'slash') {
|
|
91
|
-
const out = [];
|
|
92
|
-
const seen = new Set();
|
|
93
|
-
const lines = String(text || '').split(/\r?\n/);
|
|
94
|
-
for (const raw of lines) {
|
|
95
|
-
const ln = raw.replace(/\x1b\[[0-9;]*m/g, ''); // ANSI 색상 제거
|
|
96
|
-
if (invoke === 'subcommand') {
|
|
97
|
-
const m = ln.match(/^\s{2,}([a-z][a-z0-9][\w-]*)\s{2,}(\S.*)$/);
|
|
98
|
-
if (m && !/^--/.test(m[1])) {
|
|
99
|
-
const cmd = m[1];
|
|
100
|
-
if (!seen.has(cmd) && cmd.length <= 24) { seen.add(cmd); out.push({ cmd, desc: m[2].trim().slice(0, 80) }); }
|
|
101
|
-
}
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const m = ln.match(/^\s*(\/[a-zA-Z][\w-]*)(?:\s+[-–:]?\s*(.*))?$/);
|
|
105
|
-
if (m) {
|
|
106
|
-
const cmd = m[1];
|
|
107
|
-
if (!seen.has(cmd) && cmd.length <= 24) { seen.add(cmd); out.push({ cmd, desc: (m[2] || '').trim().slice(0, 80) }); }
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return out;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 1.9.283 (UR-0025 2단계): 권한 등급(permission tiers) 순수 로직 — capabilities/policy 공유.
|
|
114
|
-
const PERMISSION_TIERS = ['read-only', 'safe-write', 'project-write', 'shell-read', 'shell-write', 'git-write', 'network', 'publish'];
|
|
115
|
-
function _tierRank(t) { const i = PERMISSION_TIERS.indexOf(String(t || '')); return i < 0 ? PERMISSION_TIERS.length : i; }
|
|
116
|
-
// 명령/capability → 요구 등급 (순수 매핑)
|
|
117
|
-
function _requiredTier(cmd) {
|
|
118
|
-
const c = String(cmd || '').toLowerCase();
|
|
119
|
-
if (/release\s+publish|npm\s+publish|\bpublish\b/.test(c)) return 'publish';
|
|
120
|
-
if (/\bweb\b/.test(c)) return 'network';
|
|
121
|
-
if (/git\s+push|sync-main/.test(c)) return 'git-write';
|
|
122
|
-
if (/multi\s+--execute|dispatch\s+--write|--yolo|\bpc\b/.test(c)) return 'shell-write';
|
|
123
|
-
if (/agents\s+(list|quota|bench)|--run-tests/.test(c)) return 'shell-read';
|
|
124
|
-
if (/\binit\b|\badapter\b|update\s+--yes|\bmigrate\b/.test(c)) return 'project-write';
|
|
125
|
-
if (/state\s+(start|record|verify|handoff)|decision|lesson|plan\s+add|task\s+add|rule\s+add/.test(c)) return 'safe-write';
|
|
126
|
-
return 'read-only';
|
|
127
|
-
}
|
|
128
|
-
function _policyAllows(allowedTier, requiredTier) { return _tierRank(requiredTier) <= _tierRank(allowedTier); }
|
|
129
|
-
|
|
130
|
-
// 1.9.283: npm dist-tag 결정 (UR-0026) — latest(안정)/next(실험), 잘못된 형식은 latest.
|
|
131
|
-
function _resolveNpmTag(explicit, env) {
|
|
132
|
-
env = env || process.env;
|
|
133
|
-
const raw = String(explicit || env.LEERNESS_NPM_TAG || 'latest').trim().toLowerCase();
|
|
134
|
-
return /^[a-z][a-z0-9-]{0,38}$/.test(raw) ? raw : 'latest';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 1.9.283: .mcp.json 내용 (UR-0033) — leerness MCP 서버 등록.
|
|
138
|
-
function _mcpJsonContent() {
|
|
139
|
-
return JSON.stringify({ mcpServers: { leerness: { command: 'npx', args: ['leerness', 'mcp', 'serve'] } } }, null, 2) + '\n';
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 1.9.283: run 레코드 빌더 (UR-0032) — GPT-5.5 권고 14필드. startedAt 주입 가능(테스트).
|
|
143
|
-
function _newRunRecord(opts = {}) {
|
|
144
|
-
return {
|
|
145
|
-
schemaVersion: 1,
|
|
146
|
-
run_id: opts.run_id || null,
|
|
147
|
-
task_id: opts.task_id || null,
|
|
148
|
-
agent_name: opts.agent_name || null,
|
|
149
|
-
model_name: opts.model_name || null,
|
|
150
|
-
started_at: opts.started_at || new Date().toISOString(),
|
|
151
|
-
ended_at: opts.ended_at || null,
|
|
152
|
-
goal: opts.goal || '',
|
|
153
|
-
files_read: Array.isArray(opts.files_read) ? opts.files_read : [],
|
|
154
|
-
files_changed: Array.isArray(opts.files_changed) ? opts.files_changed : [],
|
|
155
|
-
commands_run: Array.isArray(opts.commands_run) ? opts.commands_run : [],
|
|
156
|
-
tests_run: Array.isArray(opts.tests_run) ? opts.tests_run : [],
|
|
157
|
-
errors: Array.isArray(opts.errors) ? opts.errors : [],
|
|
158
|
-
decisions: Array.isArray(opts.decisions) ? opts.decisions : [],
|
|
159
|
-
verification_result: opts.verification_result || null,
|
|
160
|
-
handoff_summary: opts.handoff_summary || null,
|
|
161
|
-
status: opts.status || 'in-progress'
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 1.9.443 (GPT-5.5 전략리뷰 §6.3/6.4, UR-0153): evidence-first 완료 게이트 — run-record 증거로 "완료 주장 가능" 여부 파생.
|
|
166
|
-
// 허용 조건: 변경 파일 존재 + 검증 실행(tests/commands) + 미해결 errors 0 + verification_result === 'pass'.
|
|
167
|
-
// verification 미실행/실패는 불허(증거 없는 완료 차단). reasons 로 불허 사유 명시. 순수 함수(저장 X, 읽을 때 계산).
|
|
168
|
-
function _completionClaimAllowed(rec) {
|
|
169
|
-
const r = rec || {};
|
|
170
|
-
const A = (x) => (Array.isArray(x) ? x : []);
|
|
171
|
-
const reasons = [];
|
|
172
|
-
if (A(r.files_changed).length === 0) reasons.push('no_files_changed');
|
|
173
|
-
if (A(r.tests_run).length === 0 && A(r.commands_run).length === 0) reasons.push('no_verification_run');
|
|
174
|
-
if (A(r.errors).length > 0) reasons.push('unresolved_errors');
|
|
175
|
-
const vr = String(r.verification_result || '').toLowerCase();
|
|
176
|
-
if (vr === 'fail') reasons.push('verification_failed');
|
|
177
|
-
else if (vr !== 'pass') reasons.push('not_verified');
|
|
178
|
-
return { allowed: reasons.length === 0, reasons };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// 1.9.318 (UR-0025): 순수 HTML 파싱 유틸 (api-skill 문서 수집용) — fs/네트워크 의존 0, URL/regex 만 사용.
|
|
182
|
-
function _htmlToText(html) {
|
|
183
|
-
if (!html) return '';
|
|
184
|
-
return html
|
|
185
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
186
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
187
|
-
.replace(/<!--[\s\S]*?-->/g, '')
|
|
188
|
-
.replace(/<br\s*\/?>/gi, '\n')
|
|
189
|
-
.replace(/<\/?(p|div|li|h[1-6]|tr|td|pre)[^>]*>/gi, '\n')
|
|
190
|
-
.replace(/<[^>]+>/g, ' ')
|
|
191
|
-
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
192
|
-
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
193
|
-
.replace(/[ \t]+/g, ' ').replace(/\n\s*\n\s*\n+/g, '\n\n').trim();
|
|
194
|
-
}
|
|
195
|
-
function _extractTitle(html) {
|
|
196
|
-
const m = (html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
197
|
-
if (!m) return '';
|
|
198
|
-
return _htmlToText(m[1]).slice(0, 200);
|
|
199
|
-
}
|
|
200
|
-
function _extractLinks(html, baseUrl, maxLinks) {
|
|
201
|
-
if (!html) return [];
|
|
202
|
-
const base = new URL(baseUrl);
|
|
203
|
-
const found = new Map();
|
|
204
|
-
const re = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
205
|
-
let m;
|
|
206
|
-
while ((m = re.exec(html)) !== null) {
|
|
207
|
-
let href = m[1];
|
|
208
|
-
if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:')) continue;
|
|
209
|
-
let abs;
|
|
210
|
-
try { abs = new URL(href, baseUrl).toString(); } catch { continue; }
|
|
211
|
-
const u = new URL(abs);
|
|
212
|
-
if (u.hostname !== base.hostname) continue; // same-domain only
|
|
213
|
-
if (abs === baseUrl) continue;
|
|
214
|
-
if (found.has(abs)) continue;
|
|
215
|
-
const text = _htmlToText(m[2]).slice(0, 120);
|
|
216
|
-
found.set(abs, { url: abs, text });
|
|
217
|
-
if (found.size >= (maxLinks || 10)) break;
|
|
218
|
-
}
|
|
219
|
-
return Array.from(found.values());
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// 1.9.324 (UR-0025): 순수 메모리 MD 파서 — 코드펜스(```md 템플릿 예시) 제거 후 날짜 블록(### YYYY-MM-DD) 카운트/추출.
|
|
223
|
-
// count drift(템플릿 오집계) 방지의 단일 진실소스. decisions/lessons 카운터가 공유.
|
|
224
|
-
function _countDatedBlocks(text) {
|
|
225
|
-
const cleaned = String(text || '').replace(/^```[^\n]*\n[\s\S]*?\n```\s*$/gm, ''); // 코드펜스(템플릿) 제거
|
|
226
|
-
return (cleaned.match(/^### \d{4}-\d{2}-\d{2}/gm) || []).length;
|
|
227
|
-
}
|
|
228
|
-
function _extractDecisionBlocks(text) {
|
|
229
|
-
// 줄 시작의 ```부터 줄 시작의 ```까지를 코드블록으로 인식 (인라인 백틱 무시)
|
|
230
|
-
const cleaned = String(text || '').replace(/^```[^\n]*\n[\s\S]*?\n```\s*$/gm, '');
|
|
231
|
-
return cleaned.split(/\n(?=### )/).filter(b =>
|
|
232
|
-
b.startsWith('### ') && !/^### (Template|템플릿)\b/.test(b.trim())
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// 1.9.325 (UR-0025): 순수 intent 분류 — 사용자 텍스트의 precise/broad 신호로 의도 추정 (fs/상태 의존 0).
|
|
237
|
-
function _classifyIntent(text) {
|
|
238
|
-
if (!text || typeof text !== 'string') return { intent: 'default', signals: [] };
|
|
239
|
-
const signals = [];
|
|
240
|
-
// precise 신호: "정확히 / 그것만 / 그대로 / only / just / 만"
|
|
241
|
-
const preciseKws = ['정확히', '그것만', '그대로', 'only', 'just only', '말한대로', '말한 그대로'];
|
|
242
|
-
for (const kw of preciseKws) {
|
|
243
|
-
if (text.toLowerCase().includes(kw.toLowerCase())) signals.push({ kind: 'precise', match: kw });
|
|
244
|
-
}
|
|
245
|
-
// broad 신호: "기본 / 포괄적 / 등등 / 다양한 / 전체 / 기본적인 / etc / overall"
|
|
246
|
-
const broadKws = ['기본', '포괄적', '등등', '다양한', '전체', '기본적인', 'etc', 'overall', '필요한', '관련', 'comprehensive', 'including'];
|
|
247
|
-
for (const kw of broadKws) {
|
|
248
|
-
if (text.toLowerCase().includes(kw.toLowerCase())) signals.push({ kind: 'broad', match: kw });
|
|
249
|
-
}
|
|
250
|
-
const preciseCount = signals.filter(s => s.kind === 'precise').length;
|
|
251
|
-
const broadCount = signals.filter(s => s.kind === 'broad').length;
|
|
252
|
-
let intent;
|
|
253
|
-
if (preciseCount > broadCount && preciseCount >= 1) intent = 'precise';
|
|
254
|
-
else if (broadCount >= 1) intent = 'broad';
|
|
255
|
-
else intent = 'default';
|
|
256
|
-
return { intent, signals, preciseCount, broadCount };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// 1.9.326 (UR-0025): 순수 문자열/셸/env 유틸.
|
|
260
|
-
// 코드펜스(```) 중립화 — 임베딩 텍스트가 외부 마크다운을 깨지 않게. (``` → ''', 인라인 백틱 보존)
|
|
261
|
-
function _sanitizeFences(s) { return String(s || '').replace(/```+/g, "'''"); }
|
|
262
|
-
// shell:true spawn 인자 셸-안전 인용 — POSIX(sh) single-quote / Windows(cmd) double-quote + inner " 이스케이프.
|
|
263
|
-
function _shellQuoteArg(s) {
|
|
264
|
-
s = String(s == null ? '' : s);
|
|
265
|
-
if (process.platform === 'win32') return '"' + s.replace(/"/g, '""') + '"';
|
|
266
|
-
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
267
|
-
}
|
|
268
|
-
// Windows PowerShell 실행 env 감지 — pwsh 6/7 신뢰 마커(POWERSHELL_DISTRIBUTION_CHANNEL / pwsh 전용 경로)만 판별(ps5.1 자동판별 안 함).
|
|
269
|
-
function _detectPwshFromEnv(e) {
|
|
270
|
-
e = e || process.env;
|
|
271
|
-
const channel = e.POWERSHELL_DISTRIBUTION_CHANNEL || '';
|
|
272
|
-
const pmp = e.PSModulePath || '';
|
|
273
|
-
if (channel || /[\\/]PowerShell[\\/][67][\\/]/i.test(pmp) || /Documents[\\/]+PowerShell[\\/]/i.test(pmp)) {
|
|
274
|
-
return { isPowerShell: true, version: '7', edition: 'Core' };
|
|
275
|
-
}
|
|
276
|
-
return { isPowerShell: false, version: null, edition: null };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 1.9.327 (UR-0025): 순수 TZ/날짜 포맷 — ISO UTC 저장 유지, 표시 시 local 변환 (env LEERNESS_TZ / 시스템 tz / Asia/Seoul fallback).
|
|
280
|
-
function _getLocalTz() {
|
|
281
|
-
if (process.env.LEERNESS_TZ) return process.env.LEERNESS_TZ;
|
|
282
|
-
try {
|
|
283
|
-
const sys = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
284
|
-
if (sys && sys !== 'UTC') return sys;
|
|
285
|
-
} catch {}
|
|
286
|
-
return 'Asia/Seoul';
|
|
287
|
-
}
|
|
288
|
-
function _formatLocal(iso, opts) {
|
|
289
|
-
if (!iso) return '?';
|
|
290
|
-
opts = opts || {};
|
|
291
|
-
const tz = opts.tz || _getLocalTz();
|
|
292
|
-
try {
|
|
293
|
-
const d = typeof iso === 'string' ? new Date(iso) : iso;
|
|
294
|
-
if (isNaN(d.getTime())) return String(iso);
|
|
295
|
-
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
296
|
-
timeZone: tz,
|
|
297
|
-
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
298
|
-
hour: '2-digit', minute: '2-digit',
|
|
299
|
-
hour12: false
|
|
300
|
-
});
|
|
301
|
-
const parts = fmt.formatToParts(d);
|
|
302
|
-
const get = (t) => (parts.find(p => p.type === t) || {}).value || '';
|
|
303
|
-
const date = `${get('year')}-${get('month')}-${get('day')}`;
|
|
304
|
-
const time = `${get('hour')}:${get('minute')}`;
|
|
305
|
-
const tzShort = tz === 'Asia/Seoul' ? 'KST' : tz === 'Asia/Tokyo' ? 'JST' : tz === 'UTC' ? 'UTC' : tz.split('/').pop().slice(0, 3);
|
|
306
|
-
return opts.dateOnly ? date : `${date} ${time} ${tzShort}`;
|
|
307
|
-
} catch { return String(iso); }
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// 1.9.328 (UR-0025): 순수 문자열 유틸 — 절단(말줄임표) / 콤마 리스트 분할.
|
|
311
|
-
function _truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
312
|
-
function _splitList(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
|
|
313
|
-
|
|
314
|
-
// 1.9.329 (UR-0025): 순수 roadmap MD 파서 — 상태 정규화 / 마일스톤·토큰 추출 (fs 의존 0).
|
|
315
|
-
function _roadmapMapStatus(s) {
|
|
316
|
-
s = String(s || '').toLowerCase();
|
|
317
|
-
if (s === 'done' || s === 'in-progress' || s === 'on-hold' || s === 'waiting' || s === 'incomplete' || s === 'blocked' || s === 'dropped') return s;
|
|
318
|
-
if (s === 'planned' || s === 'requested') return 'planned';
|
|
319
|
-
return 'planned';
|
|
320
|
-
}
|
|
321
|
-
function _roadmapParseMilestones(text) {
|
|
322
|
-
const s = String(text || '');
|
|
323
|
-
const out = [];
|
|
324
|
-
// 1.9.352 (UR-0068 외부리뷰): 다음 milestone 직전까지 block 한정 — 이전 구현은 slice(m.index) 로 다음 milestone 의 Status/Progress 를 누출했음
|
|
325
|
-
const matches = [...s.matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)];
|
|
326
|
-
for (let i = 0; i < matches.length; i++) {
|
|
327
|
-
const m = matches[i];
|
|
328
|
-
const end = i + 1 < matches.length ? matches[i + 1].index : s.length;
|
|
329
|
-
const block = s.slice(m.index, end);
|
|
330
|
-
const sm = block.match(/^Status:\s*(\S+)/m);
|
|
331
|
-
const pm = block.match(/^Progress:\s*(\d+)%/m);
|
|
332
|
-
out.push({ id: m[1], title: m[2].trim(), status: sm ? sm[1] : 'planned', progress: pm ? parseInt(pm[1], 10) : 0 });
|
|
333
|
-
}
|
|
334
|
-
return out;
|
|
335
|
-
}
|
|
336
|
-
function _roadmapParseTokens(text) {
|
|
337
|
-
const tokens = {};
|
|
338
|
-
for (const line of String(text || '').split('\n')) {
|
|
339
|
-
const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
|
|
340
|
-
if (!m) continue;
|
|
341
|
-
const key = m[1].trim(), val = m[2].trim();
|
|
342
|
-
if (!key || !val || key === 'Token' || /^-+$/.test(key) || val === 'Value' || /\(실제 값으로 업데이트\)/.test(val)) continue;
|
|
343
|
-
if (val.length > 80) continue;
|
|
344
|
-
tokens[key] = val;
|
|
345
|
-
}
|
|
346
|
-
return tokens;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// 1.9.330 (UR-0025): project-brief 필드 config(순수 데이터) + 채움 카운트 derivation.
|
|
350
|
-
const _BRIEF_FIELDS = [
|
|
351
|
-
{ key: 'intro', h: 'Intro', label: '소개', flag: 'intro', multi: false },
|
|
352
|
-
{ key: 'purpose', h: 'Purpose', label: '목적', flag: 'purpose', multi: false },
|
|
353
|
-
{ key: 'problem', h: 'Problem', label: '해결 문제', flag: 'problem', multi: false },
|
|
354
|
-
{ key: 'features', h: 'Features', label: '핵심 기능', flag: 'features', multi: true },
|
|
355
|
-
{ key: 'stack', h: 'Tech Stack', label: '기술 스택', flag: 'stack', multi: true },
|
|
356
|
-
{ key: 'architecture', h: 'Architecture', label: '아키텍처', flag: 'architecture', multi: false },
|
|
357
|
-
{ key: 'users', h: 'Users', label: '사용자', flag: 'users', multi: true },
|
|
358
|
-
{ key: 'success', h: 'Success Criteria', label: '성공 기준', flag: 'success', multi: true },
|
|
359
|
-
{ key: 'nonGoals', h: 'Non-Goals', label: '비목표', flag: 'non-goals', multi: true },
|
|
360
|
-
{ key: 'currentState', h: 'Current State', label: '현재 상태', flag: 'current-state', multi: false },
|
|
361
|
-
];
|
|
362
|
-
function _briefFilled(brief) { return _BRIEF_FIELDS.filter(f => (f.multi ? (brief[f.key] && brief[f.key].length) : brief[f.key])).length; }
|
|
363
|
-
// 1.9.331 (UR-0025): project-brief 텍스트 빌더 (순수) — README 개요 블록 / 복사용 청사진. VERSION 은 인자로 주입.
|
|
364
|
-
const BRIEF_START = '<!-- leerness:project-brief:start -->';
|
|
365
|
-
const BRIEF_END = '<!-- leerness:project-brief:end -->';
|
|
366
|
-
function _briefReadmeBlock(brief) {
|
|
367
|
-
const L = [BRIEF_START, '## 프로젝트 개요', ''];
|
|
368
|
-
if (brief.intro) L.push(brief.intro, '');
|
|
369
|
-
if (brief.purpose) L.push(`**목적**: ${brief.purpose}`, '');
|
|
370
|
-
if (brief.problem) L.push(`**해결 문제**: ${brief.problem}`, '');
|
|
371
|
-
if (brief.features && brief.features.length) { L.push('**핵심 기능**'); brief.features.forEach(x => L.push(`- ${x}`)); L.push(''); }
|
|
372
|
-
if (brief.stack && brief.stack.length) L.push(`**기술 스택**: ${brief.stack.join(', ')}`, '');
|
|
373
|
-
if (brief.directionHistory && brief.directionHistory.length) { L.push('**최근 개발 방향 변경**'); brief.directionHistory.slice(-3).forEach(x => L.push(`- ${x}`)); L.push(''); }
|
|
374
|
-
if (_briefFilled(brief) === 0) L.push('_아직 개요 미입력 — `leerness brief set --intro "..." --purpose "..."` 로 작성._', '');
|
|
375
|
-
L.push('<sub>이 섹션은 `leerness brief` 로 관리됩니다. 전체 청사진(복사용): `leerness brief export`.</sub>', BRIEF_END);
|
|
376
|
-
return L.join('\n');
|
|
377
|
-
}
|
|
378
|
-
function _briefBlueprint(brief, version) {
|
|
379
|
-
const L = [`# ${brief.project} — 프로젝트 청사진 (Blueprint)`,
|
|
380
|
-
`> 이 문서만으로 프로젝트를 기초부터 재구성할 수 있도록 작성. \`leerness brief export\` 생성 (leerness v${version || '?'}).`, ''];
|
|
381
|
-
const sec = (h, v, multi) => { if (multi ? (v && v.length) : v) { L.push(`## ${h}`, multi ? v.map(x => `- ${x}`).join('\n') : v, ''); } };
|
|
382
|
-
sec('소개 (Intro)', brief.intro); sec('목적 (Purpose)', brief.purpose); sec('해결 문제 (Problem)', brief.problem);
|
|
383
|
-
sec('핵심 기능 (Features)', brief.features, true); sec('기술 스택 (Tech Stack)', brief.stack, true);
|
|
384
|
-
sec('아키텍처 (Architecture)', brief.architecture); sec('사용자 (Users)', brief.users, true);
|
|
385
|
-
sec('성공 기준 (Success Criteria)', brief.success, true); sec('비목표 (Non-Goals)', brief.nonGoals, true);
|
|
386
|
-
sec('현재 상태 (Current State)', brief.currentState);
|
|
387
|
-
sec('개발 방향 이력 (Direction History)', brief.directionHistory, true);
|
|
388
|
-
L.push('---', '## 신규 프로젝트 시작 가이드', '', '1. 위 소개·목적·기능·아키텍처·스택을 신규 레포의 계획으로 복사.', '2. `leerness init .` 후 이 파일을 `.harness/project-brief.md` 로 복사하거나 `leerness brief set` 으로 재입력.', '3. Features 를 `leerness plan add` / `leerness task add` 로 분해.', '');
|
|
389
|
-
return L.join('\n');
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// 1.9.332 (UR-0025): 순수 lessons.md 파서 — 블록(### 날짜)→엔트리 {date, text, tag}. 필터는 호출측.
|
|
393
|
-
function _parseLessonEntries(text) {
|
|
394
|
-
const out = [];
|
|
395
|
-
for (const block of String(text || '').split(/\n(?=### )/)) {
|
|
396
|
-
if (!block.startsWith('### ')) continue;
|
|
397
|
-
const dateMatch = block.match(/^### (\d{4}-\d{2}-\d{2}[^\n]*)/);
|
|
398
|
-
const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
|
|
399
|
-
const tagMatch = block.match(/- Tag:[ \t]*(.+)/);
|
|
400
|
-
if (!lessonMatch) continue;
|
|
401
|
-
out.push({ date: dateMatch ? dateMatch[1].trim() : null, text: lessonMatch[1].trim(), tag: tagMatch ? tagMatch[1].trim() : null });
|
|
402
|
-
}
|
|
403
|
-
return out;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// UR-0058: canonical lessons 객체 배열 → lessons.md projection. _parseLessonEntries 와 round-trip 안전.
|
|
407
|
-
function _renderLessonsMd(lessons) {
|
|
408
|
-
const preamble = '# Lessons (1.9.112)\n\n과거 실수/통찰/패턴 영구 기록 — handoff 자동 회수와 통합.\n';
|
|
409
|
-
const body = (lessons || []).map(l =>
|
|
410
|
-
// 1.9.402 (UR-0108): text/tag 개행 → 공백(MD projection 라인 위조 차단). canonical JSON 은 raw 유지.
|
|
411
|
-
`\n### ${_lineSafe(l.date)}\n- Lesson: ${_lineSafe(l.text)}\n${l.tag ? `- Tag: ${_lineSafe(l.tag)}\n` : ''}`
|
|
412
|
-
).join('');
|
|
413
|
-
return preamble + body;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// 1.9.341 (UR-0025 심층): 내장 스킬 catalog → _source:'builtin' 부여 맵 (skillpack fallback 순수 변환).
|
|
417
|
-
function _withBuiltinSource(catalog) {
|
|
418
|
-
const out = {};
|
|
419
|
-
for (const [k, v] of Object.entries(catalog || {})) out[k] = { ...v, _source: 'builtin' };
|
|
420
|
-
return out;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// 1.9.345 (UR-0025 심층): HTML escape (roadmap.html 등 출력 인젝션 방지) — 순수, null-safe.
|
|
424
|
-
function _esc(s) {
|
|
425
|
-
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// 1.9.346 (UR-0025 심층): roadmap.html :root CSS 변수 빌더 — designTokens/cssVariables 주입, 순수(모듈 의존 0).
|
|
429
|
-
function _roadmapTokenStyles(designTokens, cssVariables) {
|
|
430
|
-
const dt = designTokens || {}, cv = cssVariables || {};
|
|
431
|
-
const vars = {};
|
|
432
|
-
const map = [
|
|
433
|
-
['color.primary', 'color-primary', 'lr-primary'], ['color.surface', 'color-surface', 'lr-surface'],
|
|
434
|
-
['color.text', 'color-text', 'lr-text'], ['color.muted', 'color-muted', 'lr-muted'],
|
|
435
|
-
['space.1', 'space-1', 'lr-space-1'], ['space.2', 'space-2', 'lr-space-2'],
|
|
436
|
-
['space.3', 'space-3', 'lr-space-3'], ['space.4', 'space-4', 'lr-space-4'],
|
|
437
|
-
['radius', 'radius', 'lr-radius']
|
|
438
|
-
];
|
|
439
|
-
for (const [ds, css, vn] of map) { const v = cv[css] || dt[ds]; if (v) vars[vn] = v; }
|
|
440
|
-
for (const [k, v] of Object.entries(cv)) if (!vars[`lr-${k}`]) vars[`lr-${k}`] = v;
|
|
441
|
-
if (!vars['lr-card-bg']) vars['lr-card-bg'] = vars['lr-surface'] || '#ffffff';
|
|
442
|
-
if (!vars['lr-edge']) vars['lr-edge'] = vars['lr-muted'] || '#cbd5e1';
|
|
443
|
-
if (!vars['lr-page-bg']) vars['lr-page-bg'] = '#f8fafc';
|
|
444
|
-
// 1.9.350 (UR-0060/0061 외부리뷰): CSS 값 살균 — whitelist 로 } < > ; { @ : / 등 제거(:root 규칙 breakout + </style> HTML 탈출 차단). 색상/길이 형식은 보존.
|
|
445
|
-
const _safeCss = v => String(v == null ? '' : v).replace(/[^#a-zA-Z0-9(),.%\s_-]/g, '').slice(0, 80);
|
|
446
|
-
return ':root {\n' + Object.entries(vars).map(([k, v]) => ` --${k}: ${_safeCss(v)};`).join('\n') + '\n }';
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// 1.9.347 (UR-0025 심층): SKILL.md frontmatter 파서 — { meta, body }, BOM-aware (Windows Notepad 호환). 순수.
|
|
450
|
-
function _parseSkillMd(text) {
|
|
451
|
-
// 1.9.408 (8번째 버그헌트, UR-0112): BOM strip + CRLF/CR→LF 정규화.
|
|
452
|
-
// 기존 버그: frontmatter 값 정규식 (.+)$ 의 '.'은 CR(\r)을 매칭 못 해 'name: x\r' 라인이 통째로 실패 → CRLF SKILL.md(Windows/Notepad)의 meta 전체 소실 → skill install "name 필수" 실패.
|
|
453
|
-
const cleaned = String(text || '').replace(/^/, '').replace(/\r\n?/g, '\n'); // BOM strip (U+FEFF) + 줄바꿈 정규화
|
|
454
|
-
const m = cleaned.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
455
|
-
if (!m) return { meta: {}, body: cleaned };
|
|
456
|
-
const meta = {};
|
|
457
|
-
for (const line of m[1].split('\n')) {
|
|
458
|
-
const km = line.match(/^([a-zA-Z_-]+):\s*(.+)$/);
|
|
459
|
-
if (km) meta[km[1].trim()] = km[2].trim().replace(/^["']|["']$/g, '');
|
|
460
|
-
}
|
|
461
|
-
return { meta, body: m[2] };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// 1.9.333 (UR-0025 심층): 순수 플랫폼 제약 매칭 — catalog + 텍스트 → 매칭 플랫폼/제약/제안 (fs 의존 0, catalog 주입).
|
|
465
|
-
function _matchConstraints(catalog, text) {
|
|
466
|
-
if (!text || typeof text !== 'string' || !catalog || !catalog.platforms) return { matched: [], suggestions: [] };
|
|
467
|
-
const lower = text.toLowerCase();
|
|
468
|
-
const matched = [];
|
|
469
|
-
for (const [pid, plat] of Object.entries(catalog.platforms)) {
|
|
470
|
-
const aliases = plat.aliases || [];
|
|
471
|
-
const hit = aliases.find(a => lower.includes(a.toLowerCase()));
|
|
472
|
-
if (hit) matched.push({ platform: pid, matchedAlias: hit, docs: plat.docs, constraints: plat.constraints });
|
|
473
|
-
}
|
|
474
|
-
const suggestions = [];
|
|
475
|
-
const generic = /\bapi\b|연동|integration|호출|rate|limit|quota|webhook/i.test(text);
|
|
476
|
-
if (generic && matched.length === 0) {
|
|
477
|
-
suggestions.push('일반적 API 연동 키워드 감지 — leerness constraints list 로 사전 등록된 플랫폼 catalog 확인 권장');
|
|
478
|
-
}
|
|
479
|
-
return { matched, suggestions, totalPlatforms: Object.keys(catalog.platforms).length };
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// 1.9.333 패턴 적용: 순수 도메인 매칭 — catalog + 텍스트 → 첫 매칭 domain/alias/components (fs 의존 0, catalog 주입).
|
|
483
|
-
function _matchDomain(catalog, text) {
|
|
484
|
-
if (!text || typeof text !== 'string' || !catalog || !catalog.domains) return { domain: null, alias: null };
|
|
485
|
-
const lower = text.toLowerCase();
|
|
486
|
-
for (const [domain, info] of Object.entries(catalog.domains)) {
|
|
487
|
-
for (const a of info.aliases || []) {
|
|
488
|
-
if (lower.includes(a.toLowerCase())) {
|
|
489
|
-
return { domain, alias: a, components: info.components };
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
return { domain: null, alias: null };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// 1.9.335 (UR-0025 심층): LSP 서브시스템 — 순수 언어 감지 (파일 확장자 → 언어)
|
|
497
|
-
function _detectLspLang(file) {
|
|
498
|
-
const ext = ((file || '').match(/\.[a-zA-Z0-9]+$/) || [''])[0].toLowerCase();
|
|
499
|
-
if (/^\.(py|pyw|pyi)$/.test(ext)) return 'python';
|
|
500
|
-
if (ext === '.go') return 'go';
|
|
501
|
-
if (ext === '.rs') return 'rust';
|
|
502
|
-
if (/^\.(java|kt|scala)$/.test(ext)) return 'java';
|
|
503
|
-
if (/^\.(ts|tsx|js|jsx|mjs|cjs)$/.test(ext)) return 'javascript';
|
|
504
|
-
return 'javascript'; // default — 기본 JS 패턴 (.txt/.md 등 미지원 확장자)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// 1.9.335 (UR-0025 심층): LSP 서브시스템 — 순수 정규식 심볼 매처 (catalog 주입, constraints/domain 패턴 동일)
|
|
508
|
-
// catalog: { <lang>: [{ re, kind }, ...] } · content: 소스 텍스트 · lang: 언어 키
|
|
509
|
-
function _matchLspSymbols(catalog, content, lang) {
|
|
510
|
-
const symbols = [];
|
|
511
|
-
if (!catalog || typeof content !== 'string') return symbols;
|
|
512
|
-
const lines = content.split(/\r?\n/);
|
|
513
|
-
const patterns = catalog[lang || 'javascript'] || catalog.javascript || [];
|
|
514
|
-
lines.forEach((line, idx) => {
|
|
515
|
-
for (const p of patterns) {
|
|
516
|
-
const m = line.match(p.re);
|
|
517
|
-
// 키워드 false-positive 제거 (예: java method 정규식이 if(/for( 등에 매치되는 경우)
|
|
518
|
-
if (m && !/^(if|for|while|switch|catch|return|throw|new)$/.test(m[1])) {
|
|
519
|
-
symbols.push({ name: m[1], kind: p.kind, line: idx + 1 });
|
|
520
|
-
break;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
return symbols;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// 1.9.27: URL/메서드 단위 매핑 — evidence에서 "POST /users" 같은 구체 경로를 추출하고 코드에 같은 경로 존재 확인
|
|
528
|
-
function _extractUrlClaims(evidence) {
|
|
529
|
-
const claims = [];
|
|
530
|
-
// "POST /users" / "GET /api/v1/items" 등
|
|
531
|
-
const re = /\b(GET|POST|PUT|DELETE|PATCH)\s+(\/[\w\-\/]*)/gi;
|
|
532
|
-
let m;
|
|
533
|
-
while ((m = re.exec(evidence)) !== null) {
|
|
534
|
-
claims.push({ method: m[1].toUpperCase(), path: m[2] });
|
|
535
|
-
}
|
|
536
|
-
return claims;
|
|
537
|
-
}
|
|
538
|
-
function _verifyUrlClaim(claim, codeText) {
|
|
539
|
-
// claim.path 가 코드에 등장해야 함 (fetch('https://.../users') 또는 라우트 정의 'POST /users')
|
|
540
|
-
if (!claim.path || claim.path.length < 2) return true;
|
|
541
|
-
// path를 그대로 검색 (URL 또는 라우트 정의)
|
|
542
|
-
const escaped = claim.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
543
|
-
const re = new RegExp(escaped, 'i');
|
|
544
|
-
return re.test(codeText);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function _detectOptimism(patterns, evidence, codeText) {
|
|
548
|
-
// 각 패턴 검사: evidence에 주장 있고 코드에 흔적 없으면 의심
|
|
549
|
-
const suspects = [];
|
|
550
|
-
if (!Array.isArray(patterns)) return suspects;
|
|
551
|
-
for (const p of patterns) {
|
|
552
|
-
if (p.evidenceRe.test(evidence) && !p.codeRe.test(codeText)) {
|
|
553
|
-
suspects.push({ kind: p.kind, label: p.label, severity: 'high' });
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
// 1.9.27: URL/메서드 단위 매핑 — API 패턴에선 통과해도 구체 경로가 코드에 없으면 추가 의심
|
|
557
|
-
const urlClaims = _extractUrlClaims(evidence);
|
|
558
|
-
for (const claim of urlClaims) {
|
|
559
|
-
if (!_verifyUrlClaim(claim, codeText)) {
|
|
560
|
-
suspects.push({
|
|
561
|
-
kind: 'URL',
|
|
562
|
-
label: `구체 경로 "${claim.method} ${claim.path}" 코드에 미발견`,
|
|
563
|
-
severity: 'medium',
|
|
564
|
-
claim
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
return suspects;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// 1.9.27: 신뢰도 점수 (0=완전 의심, 1=신뢰)
|
|
572
|
-
// 1.9.28: high suspect 단일 케이스 floor 0.15 — 단일 의심도 정량 차등 가능하게
|
|
573
|
-
function _computeConfidence(patterns, evidence, codeText) {
|
|
574
|
-
if (!Array.isArray(patterns)) return 1.0;
|
|
575
|
-
const suspects = _detectOptimism(patterns, evidence, codeText);
|
|
576
|
-
const high = suspects.filter(s => s.severity === 'high').length;
|
|
577
|
-
const medium = suspects.filter(s => s.severity === 'medium').length;
|
|
578
|
-
// 가중치: high 1.0 / medium 0.5
|
|
579
|
-
const totalPenalty = high * 1.0 + medium * 0.5;
|
|
580
|
-
// 패턴 검사로 발견된 evidence 주장이 많을수록 신뢰도 산정 base 변경
|
|
581
|
-
const evidenceClaims = patterns.filter(p => p.evidenceRe.test(evidence)).length + _extractUrlClaims(evidence).length;
|
|
582
|
-
if (evidenceClaims === 0) return 1.0; // 외부 작용 주장 자체가 없으면 신뢰 1.0
|
|
583
|
-
let confidence = Math.max(0, 1 - totalPenalty / evidenceClaims);
|
|
584
|
-
// 1.9.28: single high suspect에서 confidence 0.0이 일률적 → severity 기반 floor 적용
|
|
585
|
-
if (suspects.length > 0 && high > 0 && confidence < 0.15) {
|
|
586
|
-
// 의심 발견은 명확하지만 0보다는 명시적 신호로
|
|
587
|
-
confidence = 0.15;
|
|
588
|
-
}
|
|
589
|
-
return Math.round(confidence * 100) / 100;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// 1.9.337 (UR-0025 심층): persona catalog → 요약 목록 (id/name/description) 순수 변환 (list 명령 JSON 경로)
|
|
593
|
-
function _personaSummaries(catalog) {
|
|
594
|
-
return Object.values(catalog || {}).map(p => ({ id: p.id, name: p.name, description: p.description }));
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// 1.9.338 (UR-0025 심층): i18n 순수 조회 — strings catalog 주입, key → lang 값 (fallback: ko → key 자체)
|
|
598
|
-
function _translate(strings, key, lang) {
|
|
599
|
-
const entry = strings && strings[key];
|
|
600
|
-
if (!entry) return key;
|
|
601
|
-
return entry[lang || 'ko'] || entry.ko || key;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// 1.9.339 (UR-0053): decision MD 블록(문자열) → 정규 객체 (canonical 스키마). list/load 단일 파서.
|
|
605
|
-
function _parseDecisionBlock(block) {
|
|
606
|
-
const titleMatch = String(block || '').match(/^### (.+)$/m);
|
|
607
|
-
const titleLine = titleMatch ? titleMatch[1].trim() : '';
|
|
608
|
-
const dateTitle = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
|
|
609
|
-
const g = re => { const m = String(block || '').match(re); const v = m ? m[1].trim() : null; return v || null; }; // 빈 값 → null 정규화 (render↔parse round-trip 멱등)
|
|
610
|
-
return {
|
|
611
|
-
date: dateTitle ? dateTitle[1] : null,
|
|
612
|
-
title: dateTitle ? dateTitle[2].trim() : titleLine,
|
|
613
|
-
decision: g(/- Decision:[ \t]*(.+)/),
|
|
614
|
-
reason: g(/- Reason:[ \t]*(.+)/),
|
|
615
|
-
alternatives: g(/- Alternatives:[ \t]*(.+)/),
|
|
616
|
-
impact: g(/- Impact:[ \t]*(.+)/)
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// 1.9.339 (UR-0053): decisions.md 본문 → canonical 객체 배열 (template/code 블록 제외, title 있는 것만).
|
|
621
|
-
function _decisionsFromMd(text) {
|
|
622
|
-
return _extractDecisionBlocks(text).map(_parseDecisionBlock).filter(d => d.title);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// 1.9.339 (UR-0053): canonical 객체 배열 → decisions.md projection (init template preamble 보존, round-trip 안전).
|
|
626
|
-
function _renderDecisionsMd(decisions) {
|
|
627
|
-
// preamble 의 코드펜스(```)는 single-quote 문자열로 안전 처리 (template literal 충돌 회피)
|
|
628
|
-
const preamble = '# Decisions\n\n## Template (예시 — 실제 결정은 아래 코드블록 밖에 추가)\n\n'
|
|
629
|
-
+ '```md\n### YYYY-MM-DD — Decision 제목\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n```\n';
|
|
630
|
-
const body = (decisions || []).map(d => {
|
|
631
|
-
// 1.9.402 (UR-0108): 필드 개행 → 공백(MD projection '### '/'- field:' 라인 위조 블록 주입 차단). canonical JSON 은 raw 유지.
|
|
632
|
-
const head = d.date ? `${_lineSafe(d.date)} — ${_lineSafe(d.title)}` : _lineSafe(d.title);
|
|
633
|
-
return `\n### ${head}\n- Decision: ${_lineSafe(d.decision || '')}\n- Reason: ${_lineSafe(d.reason || '')}\n- Alternatives: ${_lineSafe(d.alternatives || '')}\n- Impact: ${_lineSafe(d.impact || '')}\n`;
|
|
634
|
-
}).join('');
|
|
635
|
-
return preamble + body;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// 1.9.365 (외부리뷰 CV-6/UR-0081): 시크릿 스캐너 오탐(FP) 억제 — 명백한 placeholder/예시 값은 시크릿 아님.
|
|
639
|
-
// assignment 패턴(secret/api_key = VALUE)의 VALUE 에만 적용 (provider 형식 키엔 미적용 → FN 방지).
|
|
640
|
-
function _isPlaceholderSecret(value) {
|
|
641
|
-
if (value == null) return true;
|
|
642
|
-
let v = String(value).trim().replace(/^["']|["']$/g, '').trim().toLowerCase();
|
|
643
|
-
if (!v) return true;
|
|
644
|
-
// 전체가 placeholder 토큰
|
|
645
|
-
if (/^(?:x{3,}|\*{3,}|\.{3,}|-+|0+|1234567890?|12345678|abc123|secret|password|passwd|changeme|change[-_]me|replace[-_]?me|placeholder|example|examples?|sample|dummy|test|testing|foo|bar|baz|tbd|todo|none|null|undefined|nil|empty|redacted|hidden|value|string|here)$/.test(v)) return true;
|
|
646
|
-
// 1.9.405 (8번째 버그헌트 회귀수정, UR-0109): placeholder 단어 신호를 entropy 가드보다 먼저 검사.
|
|
647
|
-
// 1.9.401 회귀: 긴 서술형 placeholder('your-super-secret-api-key-example-value')가 고엔트로피(영숫자24+ & 고유12+)를 넘어 실키로 오탐(FP).
|
|
648
|
-
// → placeholder 마커 단어가 있으면 entropy 가드 무시하고 placeholder 로 판정. 실키 prefix(sk-/AKIA 등)는 마커보다 우선(FN 방지).
|
|
649
|
-
const alnum = v.replace(/[^a-z0-9]/g, '');
|
|
650
|
-
const distinct = new Set(alnum).size;
|
|
651
|
-
const hasMarker = v.includes('example') || v.includes('placeholder') || v.includes('change-me') || v.includes('changeme') || v.includes('replace-me') || v.includes('your-') || v.includes('your_') || v.includes('my-secret') || v.includes('xxxx') || v.includes('<') || v.includes('${') || v.includes('{{');
|
|
652
|
-
const hasRealPrefix = /^(?:sk-|sk-proj-|pk_|rk_|akia|ghp_|gho_|ghs_|ghr_|github_pat_|xox[baprs]-|aiza|ya29\.|glpat-|-----begin)/.test(v);
|
|
653
|
-
// 1.9.436 (11th 외부평가 Opus P3): prefix 가 있어도 본문이 동일문자 8+연속(AKIAXXXX…/…00000000…)이면 명백한 더미 → placeholder. 실키는 고엔트로피라 무영향.
|
|
654
|
-
if (/(.)\1{7,}/.test(alnum)) return true;
|
|
655
|
-
// 1.10.1 (12th 외부평가 Opus P3, UR-0144): 'example' 로 끝나면(접미사) placeholder — AWS 공식 예제키 AKIAIOSFODNN7EXAMPLE 등.
|
|
656
|
-
// 중간에 'example' 이 있는 실키(sk-EXAMPLEab12…, sk-proj-realKEYexample…)는 접미사 아니라 미해당 → 기존 FN 정책(UR-0105) 보존. 실키는 'example' 로 끝날 확률 0.
|
|
657
|
-
if (/example$/.test(v)) return true;
|
|
658
|
-
// 실키 prefix → 항상 실키(마커 무시). 그 외 마커 단어 있으면 placeholder(고엔트로피여도). prefix 없고 마커 없고 고엔트로피 → 실키.
|
|
659
|
-
if (hasRealPrefix) return false;
|
|
660
|
-
if (hasMarker) return true;
|
|
661
|
-
if (alnum.length >= 24 && distinct >= 12) return false; // prefix·마커 없는 고엔트로피 = 실키
|
|
662
|
-
return false;
|
|
663
|
-
}
|
|
664
|
-
// 1.9.365 (외부리뷰 CV-6/UR-0081): unquoted assignment 값이 '시크릿스러운지' 판정 — 코드 식별자 오탐 억제용.
|
|
665
|
-
// 숫자 포함 8+ 또는 24+ 만 시크릿 후보 (camelCase 식별자 같은 무-숫자 단어는 제외).
|
|
666
|
-
function _looksSecretLike(value) {
|
|
667
|
-
const v = String(value || '');
|
|
668
|
-
if (!v) return false;
|
|
669
|
-
return (/\d/.test(v) && v.length >= 8) || v.length >= 24;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// 1.9.367 (UR-0025): 라인 머지 순수 코어 — 기존 텍스트에 없는 라인만 append (substring 중복 방지). mergeLinesFile 의 I/O 분리.
|
|
673
|
-
function _mergeLines(currentText, lines) {
|
|
674
|
-
let next = currentText || '';
|
|
675
|
-
for (const line of (lines || [])) if (!next.includes(line)) next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n';
|
|
676
|
-
return next;
|
|
677
|
-
}
|
|
678
|
-
// 1.9.367 (UR-0025): .env key-aware 머지 순수 코어 — 기존 KEY 값 보존(덮어쓰기 X), 신규 KEY/주석만 append. mergeEnvFile 의 I/O 분리.
|
|
679
|
-
function _mergeEnvLines(currentText, lines) {
|
|
680
|
-
const current = currentText || '';
|
|
681
|
-
const existingKeys = new Set();
|
|
682
|
-
for (const ln of current.split(/\r?\n/)) { const m = ln.match(/^\s*([A-Z][A-Z0-9_]+)\s*=/); if (m) existingKeys.add(m[1]); }
|
|
683
|
-
let next = current;
|
|
684
|
-
for (const line of (lines || [])) {
|
|
685
|
-
const km = line.match(/^\s*([A-Z][A-Z0-9_]+)\s*=/);
|
|
686
|
-
if (km) { if (existingKeys.has(km[1])) continue; next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n'; existingKeys.add(km[1]); }
|
|
687
|
-
else { if (!next.includes(line)) next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n'; }
|
|
688
|
-
}
|
|
689
|
-
return next;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// 1.9.368 (UR-0025): README 관리섹션 머지 순수 코어 — 마커 사이 교체, 없으면 append. 마커는 인자로 주입(harness 상수 비결합).
|
|
693
|
-
function _mergeReadmeSection(existing, block, START, END) {
|
|
694
|
-
if (!existing) return `# Project\n\n${block}`;
|
|
695
|
-
const s = existing.indexOf(START); const e = existing.indexOf(END);
|
|
696
|
-
if (s >= 0 && e >= s) return existing.slice(0, s).trimEnd() + '\n\n' + block + '\n' + existing.slice(e + END.length).trimStart();
|
|
697
|
-
return existing.trimEnd() + '\n\n' + block;
|
|
698
|
-
}
|
|
699
|
-
// 1.9.368 (UR-0025): 관리 파일 마이그레이션 머지 순수 코어 — 이전 내용을 migration-preserved 블록으로 보존(데이터/인덱스 파일은 overwrite).
|
|
700
|
-
// archiveRel(사전 계산된 표시 경로) + overwriteSet 을 인자로 주입 → path/process/상수 비결합(순수).
|
|
701
|
-
function _managedMerge(file, next, previous, archiveRel, overwriteSet) {
|
|
702
|
-
if (!previous || previous.trim() === next.trim()) return next;
|
|
703
|
-
const tag = '<!-- leerness:migration-preserved -->';
|
|
704
|
-
if (previous.includes(tag)) return next;
|
|
705
|
-
if (overwriteSet && overwriteSet.has(String(file).replace(/\\/g, '/'))) return next;
|
|
706
|
-
const ar = archiveRel || '.harness/archive';
|
|
707
|
-
return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\nPrevious content was backed up before migration. Archive reference:\n\n\`${ar}\`\n\n<details>\n<summary>Previous ${file}</summary>\n\n\`\`\`md\n${previous.replace(/```/g, '\\`\\`\\`')}\n\`\`\`\n\n</details>\n`;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// 1.9.369 (UR-0025): --skills 값 파싱 순수 코어 — catalog 주입(harness skillCatalog 비결합). all/recommended/csv 처리 + catalog 필터.
|
|
711
|
-
function _parseSkillsValue(v, catalog) {
|
|
712
|
-
if (!v || v === true) return [];
|
|
713
|
-
if (v === 'all') return Object.keys(catalog || {});
|
|
714
|
-
if (v === 'recommended') return ['office', 'commerce-api', 'ai-verified-skill-publisher', 'feature-implementation', 'project-roadmap-generator'];
|
|
715
|
-
return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => (catalog || {})[s]);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// 1.9.370 (UR-0025): memory archive 블록 파서 순수 코어 — "## 제거 DATE (target: \"...\")" 블록 → {date,target,originalHeader}[].
|
|
719
|
-
function _parseArchiveBlocks(text) {
|
|
720
|
-
const entries = [];
|
|
721
|
-
if (!text) return entries;
|
|
722
|
-
const blocks = text.split(/\n(?=## 제거 )/);
|
|
723
|
-
for (const b of blocks) {
|
|
724
|
-
const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
|
|
725
|
-
if (!m) continue;
|
|
726
|
-
const headerMatch = b.match(/^### (.+)$/m);
|
|
727
|
-
entries.push({ date: m[1], target: m[2], originalHeader: headerMatch ? headerMatch[1].trim() : null });
|
|
728
|
-
}
|
|
729
|
-
return entries;
|
|
730
|
-
}
|
|
731
|
-
// 1.9.370 (UR-0025): skill 카탈로그 파서 순수 코어 — JSON/RSS·Atom/markdown 링크/llms.txt 형식 → {name,url,description,format}[].
|
|
732
|
-
function _parseSkillCatalog(body, sourceUrl) {
|
|
733
|
-
const entries = [];
|
|
734
|
-
const trimmed = String(body || '').trim();
|
|
735
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
736
|
-
try {
|
|
737
|
-
const j = JSON.parse(trimmed);
|
|
738
|
-
const arr = Array.isArray(j) ? j : (j.skills || j.entries || j.items || []);
|
|
739
|
-
for (const e of arr) {
|
|
740
|
-
if (!e || (!e.name && !e.id)) continue;
|
|
741
|
-
entries.push({ name: e.name || e.id, url: e.url || e.path || (sourceUrl ? sourceUrl.replace(/[^/]+$/, '') + (e.id || e.name) + '/SKILL.md' : ''), description: e.description || '', format: 'json' });
|
|
742
|
-
}
|
|
743
|
-
if (entries.length) return entries;
|
|
744
|
-
} catch {}
|
|
745
|
-
}
|
|
746
|
-
if (/<rss|<feed|<channel|<item>/i.test(body)) {
|
|
747
|
-
for (const m of String(body).matchAll(/<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi)) {
|
|
748
|
-
const item = m[0];
|
|
749
|
-
const title = (item.match(/<title>([^<]+)<\/title>/i) || [])[1];
|
|
750
|
-
const link = (item.match(/<link[^>]*>([^<]+)<\/link>/i) || item.match(/<link\s+href="([^"]+)"/i) || [])[1];
|
|
751
|
-
const desc = (item.match(/<description>([^<]+)<\/description>/i) || item.match(/<summary>([^<]+)<\/summary>/i) || [])[1];
|
|
752
|
-
if (title) entries.push({ name: title.trim(), url: (link || '').trim(), description: (desc || '').trim(), format: 'rss' });
|
|
753
|
-
}
|
|
754
|
-
if (entries.length) return entries;
|
|
755
|
-
}
|
|
756
|
-
for (const m of String(body).matchAll(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+)\)\s*[-—:]\s*(.+)$/gm)) {
|
|
757
|
-
entries.push({ name: m[1], url: m[2], description: m[3].trim(), format: 'markdown' });
|
|
758
|
-
}
|
|
759
|
-
if (entries.length) return entries;
|
|
760
|
-
for (const m of String(body).matchAll(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+\.md)\)/gm)) {
|
|
761
|
-
entries.push({ name: m[1], url: m[2], description: '', format: 'markdown' });
|
|
762
|
-
}
|
|
763
|
-
if (entries.length) return entries;
|
|
764
|
-
for (const m of String(body).matchAll(/(https?:\/\/[^\s)]+SKILL\.md)/g)) {
|
|
765
|
-
entries.push({ name: m[1].split('/').slice(-2)[0], url: m[1], description: '', format: 'urls' });
|
|
766
|
-
}
|
|
767
|
-
return entries;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// 1.9.371 (UR-0073 Phase A): agent team 정의 → teams.md projection (canonical JSON 주, MD 투영). 순수 렌더러.
|
|
771
|
-
function _renderTeamsMd(teams) {
|
|
772
|
-
const preamble = '# Agent Teams (UR-0073)\n\n페르소나 기반 에이전트 팀 정의 — **opt-in · 정의 전용(자동 실행 없음)**. `leerness team add|list|show|remove` 로 관리.\n'
|
|
773
|
-
+ '향후 단계에서 스케줄 기반 실행(리뷰/배포/블로그)이 opt-in 으로 추가될 수 있습니다. 현재는 메타데이터만 저장합니다.\n';
|
|
774
|
-
const body = (teams || []).map(t => {
|
|
775
|
-
return `\n## ${t.id}${t.name ? ' — ' + t.name : ''}\n`
|
|
776
|
-
+ `- Purpose: ${t.purpose || ''}\n`
|
|
777
|
-
+ `- Personas: ${(t.personas || []).join(', ')}\n`
|
|
778
|
-
+ `- Members: ${(t.members || []).join(', ')}\n`
|
|
779
|
-
+ `- Schedule: ${t.schedule || 'manual'}\n`
|
|
780
|
-
+ `- Deploy: ${t.deployCommand || '-'}\n`
|
|
781
|
-
+ `- Review: ${t.review !== false ? '메인 검수 필요' : '생략'}\n`
|
|
782
|
-
+ `- Status: ${t.status || 'active'}\n`;
|
|
783
|
-
}).join('');
|
|
784
|
-
return preamble + body;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// 1.9.372 (UR-0073 Phase B): team 실행 계획 컴포저 (순수, dry-run 미리보기). 실제 실행/spawn 없음 — 멤버별 dispatch 명령 문자열만 생성.
|
|
788
|
-
function _composeTeamPlan(team, task) {
|
|
789
|
-
const t = team || {};
|
|
790
|
-
const effTask = (task && task !== true) ? String(task) : (t.purpose || '(작업 미지정)');
|
|
791
|
-
const personas = Array.isArray(t.personas) ? t.personas : [];
|
|
792
|
-
const members = Array.isArray(t.members) ? t.members : [];
|
|
793
|
-
const personaTag = personas.length ? ` [페르소나: ${personas.join(', ')}]` : '';
|
|
794
|
-
const steps = members.map(m => {
|
|
795
|
-
const prompt = `${effTask}${personaTag}`;
|
|
796
|
-
return { member: m, personas, dispatchPrompt: prompt, suggestedCommand: `leerness agents dispatch "${prompt}" --to ${m}` };
|
|
797
|
-
});
|
|
798
|
-
// 1.9.414 (UR-0119/0120): 메인 에이전트 검수 단계 — sub-agent 분배 후 메인이 산출물을 교차검증(기본 on, team.review===false 시 생략).
|
|
799
|
-
const review = t.review !== false;
|
|
800
|
-
const reviewStep = review ? {
|
|
801
|
-
type: 'review',
|
|
802
|
-
note: '메인 에이전트가 각 sub-agent 산출물을 독립 검증(교차 검수). verify-claim/contract verify/review 사용.',
|
|
803
|
-
suggestedCommand: 'leerness verify-claim <T-ID> --run-tests --strict-claims · leerness review <file> --persona ' + (personas.join(',') || 'security'),
|
|
804
|
-
} : null;
|
|
805
|
-
return { teamId: t.id || null, name: t.name || '', task: effTask, schedule: t.schedule || 'manual', memberCount: members.length, review, steps, reviewStep };
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// 1.9.373 (UR-0073 Phase C): 비-manual·active 팀의 handoff 스케줄 알림 라인 (순수). 실행 트리거 아님 — 미리보기 안내만.
|
|
809
|
-
function _teamHandoffReminders(teams) {
|
|
810
|
-
return (teams || [])
|
|
811
|
-
.filter(t => t && t.schedule && t.schedule !== 'manual' && (t.status || 'active') === 'active' && t.id)
|
|
812
|
-
.map(t => `🤝 ${t.id} (${t.schedule})${Array.isArray(t.members) && t.members.length ? ' · ' + t.members.length + '명' : ''}${t.review !== false ? ' · 검수필요' : ''} — 미리보기: leerness team preview ${t.id}`);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// 1.9.374 (UR-0074): 릴리스 케이던스 평가 (순수) — releases/day → 수준 + 권장. 외부리뷰 "릴리스 빈도 과다" 가시화.
|
|
816
|
-
function _cadenceAssessment(perDay, total, daysActive) {
|
|
817
|
-
const r = Number(perDay) || 0;
|
|
818
|
-
let level, recommendation;
|
|
819
|
-
if (r >= 5) { level = 'very-high'; recommendation = 'batched minor 릴리스 강력 권장 — 관련 패치를 묶어 주 1~2회 minor 로. stable/next 채널 분리 + 사용자에겐 stable 만 권고.'; }
|
|
820
|
-
else if (r >= 2) { level = 'high'; recommendation = 'cadence 높음 — 연관 변경을 묶어 배포 빈도 축소 권장. 릴리스 노트에 실행 환경/검증 명시.'; }
|
|
821
|
-
else if (r >= 0.5) { level = 'moderate'; recommendation = '적정 범위 — 안정성 우선 시 minor 묶음 고려.'; }
|
|
822
|
-
else { level = 'healthy'; recommendation = '건강한 케이던스.'; }
|
|
823
|
-
return { releasesPerDay: r, total: Number(total) || 0, daysActive: Number(daysActive) || 0, level, recommendation };
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// 1.9.376 (UR-0073 Phase D): team 배포 실행 게이트 결정 (순수). 안전: dry-run 기본, 실행은 --yes + env 이중 게이트.
|
|
827
|
-
// mode: no-command(설정 없음) / dry-run(실행 안 함) / gated(env 미충족 거부) / execute(실행 허용).
|
|
828
|
-
function _teamDeployGate(team, opts) {
|
|
829
|
-
const t = team || {}; opts = opts || {};
|
|
830
|
-
const command = (t.deployCommand && t.deployCommand !== true) ? String(t.deployCommand) : '';
|
|
831
|
-
if (!command) return { mode: 'no-command', command: '', message: 'deployCommand 미설정 — team add --deploy "<명령>" 으로 지정' };
|
|
832
|
-
if (!opts.yes) return { mode: 'dry-run', command, message: 'dry-run (실행 없음) — 실행하려면 --yes + LEERNESS_TEAM_DEPLOY=1' };
|
|
833
|
-
if (!opts.envOn) return { mode: 'gated', command, message: '실행 게이트 미충족 — LEERNESS_TEAM_DEPLOY=1 환경변수 필요 (의도적 opt-in)' };
|
|
834
|
-
return { mode: 'execute', command, message: '실행 허용 (--yes + env 게이트 충족)' };
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// 1.9.377 (UR-0025): 워크스페이스 레퍼런스 가이드 빌더 (순수) — dirName/version/generatedAt 주입. harness 인라인(~57줄) 분리.
|
|
838
|
-
function _renderWorkspaceReferenceGuide(dirName, version, generatedAt) {
|
|
839
|
-
const lines = [];
|
|
840
|
-
lines.push(`# Leerness Workspace Reference Guide`);
|
|
841
|
-
lines.push('');
|
|
842
|
-
lines.push(`> AI 에이전트가 leerness 워크스페이스에서 어떤 파일을 어디서 찾는지 안내합니다 (1.9.211).`);
|
|
843
|
-
lines.push('');
|
|
844
|
-
lines.push(`Generated: ${generatedAt} by leerness ${version}`);
|
|
845
|
-
lines.push(`Workspace dir: \`${dirName}/\``);
|
|
846
|
-
lines.push('');
|
|
847
|
-
lines.push(`## 📁 디렉토리 구조 (핵심)`);
|
|
848
|
-
lines.push('');
|
|
849
|
-
lines.push('```');
|
|
850
|
-
lines.push(`${dirName}/`);
|
|
851
|
-
lines.push(`├── plan.md ← 무엇을 할 것인가 (사용자 메모리)`);
|
|
852
|
-
lines.push(`├── progress-tracker.md ← 무엇을 했는가 (증거 포함, 사용자 메모리)`);
|
|
853
|
-
lines.push(`├── decisions.md ← 왜 그렇게 했는가 (사용자 메모리)`);
|
|
854
|
-
lines.push(`├── session-handoff.md ← 다음 세션 인계 (사용자 메모리)`);
|
|
855
|
-
lines.push(`├── lessons.md ← 과거 교훈 (자동 fuzzy 회수)`);
|
|
856
|
-
lines.push(`├── rules.md ← 자연어 룰 (매 세션 자동 노출, R-XXXX)`);
|
|
857
|
-
lines.push(`├── task-log.md ← in-progress / dropped task 이력`);
|
|
858
|
-
lines.push(`├── reuse-map.md ← 워크스페이스 capability 매핑`);
|
|
859
|
-
lines.push(`├── skill-suggestions.md ← skill rolling history`);
|
|
860
|
-
lines.push(`├── feature-graph.md ← 기능 의존 그래프 (F-XXXX)`);
|
|
861
|
-
lines.push(`├── manifest.json ← 워크스페이스 메타`);
|
|
862
|
-
lines.push(`├── leerness-config.json ← 비시크릿 LEERNESS_* 설정 (1.9.187, AI 가시)`);
|
|
863
|
-
lines.push(`├── user-requests.json ← 사용자 명시 요청 누적 (1.9.207)`);
|
|
864
|
-
lines.push(`├── active-wakeups.json ← ScheduleWakeup 상태 (1.9.205)`);
|
|
865
|
-
lines.push(`├── pre-wake-report.json ← sleep 전 sub-agent audit (1.9.209)`);
|
|
866
|
-
lines.push(`├── wakeup-history.json ← adaptive wakeup 이력 (1.9.210)`);
|
|
867
|
-
lines.push(`├── platform-constraints.json ← API 제약 catalog (1.9.208)`);
|
|
868
|
-
lines.push(`├── auto-resume-plan.json ← 다음 라운드 plan (1.9.203)`);
|
|
869
|
-
lines.push(`├── next-action-queue.json ← 다음 next-action 큐 (1.9.201)`);
|
|
870
|
-
lines.push(`├── last-handoff.json ← 마지막 handoff timestamp`);
|
|
871
|
-
lines.push(`├── environment.json ← 환경 변동 추적 (1.9.145)`);
|
|
872
|
-
lines.push(`├── skills/ ← 설치된 skill 디렉토리`);
|
|
873
|
-
lines.push(`└── templates/ ← 워크스페이스 템플릿`);
|
|
874
|
-
lines.push('```');
|
|
875
|
-
lines.push('');
|
|
876
|
-
lines.push(`## 🧭 자주 묻는 위치`);
|
|
877
|
-
lines.push('');
|
|
878
|
-
lines.push(`| 찾는 것 | 위치 |`);
|
|
879
|
-
lines.push(`|---|---|`);
|
|
880
|
-
lines.push(`| 현재 진행 중인 task | \`${dirName}/progress-tracker.md\` (status: in-progress) |`);
|
|
881
|
-
lines.push(`| 사용자가 명시한 영구 룰 | \`${dirName}/rules.md\` (active R-XXXX) |`);
|
|
882
|
-
lines.push(`| 직전 sleep 전 audit 결과 | \`${dirName}/pre-wake-report.json\` (1.9.209) |`);
|
|
883
|
-
lines.push(`| 미답 사용자 요청 | \`${dirName}/user-requests.json\` (status: open) |`);
|
|
884
|
-
lines.push(`| 다음 라운드 권장 단계 | \`${dirName}/auto-resume-plan.json\` (1.9.203) |`);
|
|
885
|
-
lines.push(`| API 제약 catalog | \`${dirName}/platform-constraints.json\` (1.9.208) |`);
|
|
886
|
-
lines.push(`| 자동 wakeup 권장 간격 | \`${dirName}/wakeup-history.json\` (1.9.210) |`);
|
|
887
|
-
lines.push('');
|
|
888
|
-
lines.push(`## 🔄 마이그레이션 안내`);
|
|
889
|
-
lines.push('');
|
|
890
|
-
lines.push(`이 워크스페이스는 \`.harness\` → \`.leerness\` 로 마이그레이션되었을 수 있습니다.`);
|
|
891
|
-
lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 존재 → 마이그레이션 완료, \`.leerness\` 우선 사용`);
|
|
892
|
-
lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 존재 → \`.leerness/\` 로 가야 함`);
|
|
893
|
-
lines.push(`- 양쪽 모두 없음 → 기본 \`.harness\` 사용 중`);
|
|
894
|
-
lines.push('');
|
|
895
|
-
lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 자동으로 올바른 디렉토리를 사용합니다.`);
|
|
896
|
-
lines.push('');
|
|
897
|
-
return lines.join('\n');
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// 1.9.379 (UR-0025 심화): Memory Surface 포맷 (순수) — T/D/R/P/L 카운트 → 문자열. pulse/memory-status 단일출처.
|
|
901
|
-
function _memorySurface(counts) {
|
|
902
|
-
const c = counts || {};
|
|
903
|
-
return `T${c.tasks || 0}/D${c.decisions || 0}/R${c.rules || 0}/P${c.milestones || 0}/L${c.lessons || 0}`;
|
|
904
|
-
}
|
|
905
|
-
// 1.9.379 (UR-0025 심화): pulse 한 줄 요약 조합 (순수) — gather(I/O)된 data → 한 줄 문자열. pulse 핸들러 렌더 코어.
|
|
906
|
-
function _renderPulseLine(data) {
|
|
907
|
-
const d = data || {};
|
|
908
|
-
let line = `📍 v${d.version} · 🔄 R${d.roundCount} · 🔌 MCP ${d.mcpTools} · 🧠 ${d.memorySurface}`;
|
|
909
|
-
if (d.nextMilestone) {
|
|
910
|
-
const eta = d.etaDays != null ? ` (${d.etaDays}d)` : '';
|
|
911
|
-
line += ` · 🎯 R${d.nextMilestone}${eta}`;
|
|
912
|
-
}
|
|
913
|
-
if (d.abnormalShutdown && d.abnormalShutdown !== 'none') {
|
|
914
|
-
line += ` · 🔌 abnormal:${d.abnormalShutdown}`;
|
|
915
|
-
}
|
|
916
|
-
return line;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// 1.9.429 (10th 외부평가 UR-0129): impl 소스에서 export 식별자 추출.
|
|
920
|
-
// 브레이스 균형으로 module.exports={...} 의 top-level 키만(함수 본문/중첩객체 안전 — 멀티라인 첫키만 버그 수정)
|
|
921
|
-
// + exports.foo + ESM(export function/const/let/var/class, export {a, b as c}) 인식.
|
|
922
|
-
function _parseImplExports(src) {
|
|
923
|
-
const out = new Set();
|
|
924
|
-
const add = n => { if (n && /^[A-Za-z_$][\w$]*$/.test(n)) out.add(n); };
|
|
925
|
-
// 1) module.exports = { ... } — 브레이스 균형 + top-level 키
|
|
926
|
-
const re = /module\.exports\s*=\s*\{/g; let mm;
|
|
927
|
-
while ((mm = re.exec(src))) {
|
|
928
|
-
const i = src.indexOf('{', mm.index); let depth = 0, end = -1;
|
|
929
|
-
for (let j = i; j < src.length; j++) { const c = src[j]; if (c === '{') depth++; else if (c === '}') { if (--depth === 0) { end = j; break; } } }
|
|
930
|
-
if (end < 0) break;
|
|
931
|
-
const inner = src.slice(i + 1, end);
|
|
932
|
-
let d = 0, seg = ''; const segs = [];
|
|
933
|
-
for (const c of inner) { if (c === '{' || c === '(' || c === '[') d++; else if (c === '}' || c === ')' || c === ']') d--; if (d === 0 && c === ',') { segs.push(seg); seg = ''; } else seg += c; }
|
|
934
|
-
if (seg.trim()) segs.push(seg);
|
|
935
|
-
for (const s of segs) { const m = s.match(/^\s*\.{0,3}\s*([A-Za-z_$][\w$]*)/); if (m && !/^\s*\.\.\./.test(s)) add(m[1]); }
|
|
936
|
-
re.lastIndex = end;
|
|
937
|
-
}
|
|
938
|
-
// 2) exports.foo = / module.exports.foo =
|
|
939
|
-
for (const m of src.matchAll(/(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g)) add(m[1]);
|
|
940
|
-
// 3) ESM 선언: export [async] function*/const/let/var/class foo
|
|
941
|
-
for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g)) add(m[1]);
|
|
942
|
-
// 4) ESM 목록/재export: export { foo, bar as baz } / export { default as X } from './m' → 외부이름(as 뒤) 우선.
|
|
943
|
-
// 1.9.438 (11th 외부평가 Sonnet P3, UR-0139): `default as X` 는 별칭 X 가 named export → as 별칭을 먼저 채택(이전엔 'default' 시작이라 통째로 스킵). 'export * from' 은 이름 정적불가라 미지원.
|
|
944
|
-
for (const m of src.matchAll(/export\s*\{([^}]+)\}/g)) {
|
|
945
|
-
for (const part of m[1].split(',')) {
|
|
946
|
-
const seg = part.trim(); if (!seg) continue;
|
|
947
|
-
const asM = seg.match(/\bas\s+([A-Za-z_$][\w$]*)/);
|
|
948
|
-
if (asM) { add(asM[1]); continue; } // a as b / default as b → b
|
|
949
|
-
if (/^(?:default|type)\b/.test(seg)) continue; // 단독 default / type X 제외
|
|
950
|
-
add((seg.match(/^([A-Za-z_$][\w$]*)/) || [])[1]);
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
return [...out];
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
// 1.9.
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
// 1.9.
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
//
|
|
1078
|
-
|
|
1079
|
-
//
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
//
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
};
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
// 1.9.
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
//
|
|
1326
|
-
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 1.9.274 (UR-0025 1단계, GPT-5.5 리뷰): bin/harness.js 단일 대형 파일 모듈 분리 — 점진적·비파괴 시작.
|
|
4
|
+
// 여기에는 harness 내부 상태/다른 함수에 의존하지 않는 "순수 함수"만 추출한다 (부작용 0, 단위 테스트 대상).
|
|
5
|
+
// harness.js 는 이 모듈을 require 해 동일 이름으로 사용한다. 동작 동일 — selftest 가 7종 모두 검증.
|
|
6
|
+
|
|
7
|
+
// 보안: 환경변수 키가 시크릿(TOKEN/SECRET/PASSWORD/API_KEY/PRIVATE)인지 판별.
|
|
8
|
+
function _isSecretKey(k) {
|
|
9
|
+
return /TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE/i.test(k);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// semver 비교: a>b → 1, a<b → -1, 같음 → 0. (누락 파트/null 안전)
|
|
13
|
+
function compareVer(a, b) {
|
|
14
|
+
const A = String(a || '0'), B = String(b || '0');
|
|
15
|
+
const sa = A.split('-')[0].split('.').map(n => parseInt(n || '0', 10));
|
|
16
|
+
const sb = B.split('-')[0].split('.').map(n => parseInt(n || '0', 10));
|
|
17
|
+
for (let i = 0; i < 3; i++) {
|
|
18
|
+
const x = sa[i] || 0, y = sb[i] || 0;
|
|
19
|
+
if (x > y) return 1;
|
|
20
|
+
if (x < y) return -1;
|
|
21
|
+
}
|
|
22
|
+
// 1.9.354 (UR-0072 외부리뷰): 숫자 동일 시 pre-release(-beta/-next 등) < 정식 (semver 규칙). 이전: -beta 무시 → 동일 취급.
|
|
23
|
+
const preA = A.includes('-'), preB = B.includes('-');
|
|
24
|
+
if (preA && !preB) return -1;
|
|
25
|
+
if (!preA && preB) return 1;
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// harness 버전 문자열 파싱: canonical "1.9.0" / legacy plus "leerness@1.8.0+plus@1.0.1" / "leerness@1.8.0".
|
|
30
|
+
function parseHarnessVersion(text) {
|
|
31
|
+
const t = String(text || '').trim();
|
|
32
|
+
const plus = t.match(/plus@(\d+\.\d+\.\d+)/);
|
|
33
|
+
const baseAt = t.match(/leerness@(\d+\.\d+\.\d+)/);
|
|
34
|
+
const bare = t.match(/^(\d+\.\d+\.\d+)\s*$/);
|
|
35
|
+
return {
|
|
36
|
+
plus: plus ? plus[1] : null,
|
|
37
|
+
base: baseAt ? baseAt[1] : (bare ? bare[1] : null),
|
|
38
|
+
raw: t || '(not installed)'
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// UTF-8 바이트열의 CJK 분류 (한국어/일본어/중국어/기타) — 인코딩 오인식 위험 감지용.
|
|
43
|
+
function _classifyCJK(buf, len) {
|
|
44
|
+
let korean = 0, japanese = 0, chinese = 0, other = 0, han = 0;
|
|
45
|
+
for (let i = 0; i < Math.min(buf.length, len); i++) {
|
|
46
|
+
const b = buf[i];
|
|
47
|
+
if (b < 0x80) continue;
|
|
48
|
+
if (b >= 0xEA && b <= 0xED) korean++;
|
|
49
|
+
else if (b === 0xE3) japanese++; // kana/기호 (U+3000-3FFF) — 일본어 강한 신호
|
|
50
|
+
else if (b >= 0xE4 && b <= 0xE9) han++; // CJK 통합 한자 — 한·중·일 공유라 모호
|
|
51
|
+
else other++;
|
|
52
|
+
}
|
|
53
|
+
// 1.9.354 (UR-0072 외부리뷰): 한자는 한·중·일 공유라 lead byte 만으로 판별 불가 → kana 가 있으면 일본어, 없으면 중국어로 귀속(휴리스틱). advisory 라벨 일본어 오판 완화.
|
|
54
|
+
if (japanese > 0) japanese += han; else chinese += han;
|
|
55
|
+
return { korean, japanese, chinese, other };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// CJK 분류 결과 → 위험 라벨 (Windows 코드페이지 오인식 안내).
|
|
59
|
+
function _riskLabel(cjk) {
|
|
60
|
+
if (cjk.korean >= cjk.japanese && cjk.korean >= cjk.chinese && cjk.korean > 0) {
|
|
61
|
+
return { type: 'korean', risk: 'Windows 한국어 PowerShell 에서 CP949 로 오인식 가능 (BOM 추가 권장)' };
|
|
62
|
+
}
|
|
63
|
+
if (cjk.japanese > cjk.korean && cjk.japanese >= cjk.chinese) {
|
|
64
|
+
return { type: 'japanese', risk: 'Windows 일본어 PowerShell 에서 CP932 (Shift-JIS) 로 오인식 가능 (BOM 추가 권장)' };
|
|
65
|
+
}
|
|
66
|
+
if (cjk.chinese > 0) {
|
|
67
|
+
return { type: 'chinese', risk: 'Windows 중국어 PowerShell 에서 CP936 (GBK) 로 오인식 가능 (BOM 추가 권장)' };
|
|
68
|
+
}
|
|
69
|
+
return { type: 'non-ascii', risk: 'Windows 비-ASCII 셸 스크립트 — BOM 없는 UTF-8 인코딩 오인식 가능 (BOM 추가 권장)' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// OS 시스템 언어 감지 (UR-0022): POSIX env > Intl ICU locale > null.
|
|
73
|
+
function _detectSystemLang(env) {
|
|
74
|
+
env = env || process.env;
|
|
75
|
+
const raw = String(env.LC_ALL || env.LC_CTYPE || env.LANG || env.LANGUAGE || '').toLowerCase();
|
|
76
|
+
if (raw && raw !== 'c' && raw !== 'posix') {
|
|
77
|
+
if (/(^|[^a-z])ko([_\-.]|$)|korean|[_-]kr([_\-.]|$)/.test(raw)) return 'ko';
|
|
78
|
+
if (/(^|[^a-z])en([_\-.]|$)|english|[_-](us|gb)([_\-.]|$)/.test(raw)) return 'en';
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const loc = (Intl.DateTimeFormat().resolvedOptions().locale || '').toLowerCase();
|
|
82
|
+
const primary = loc.split('-')[0];
|
|
83
|
+
if (primary === 'ko') return 'ko';
|
|
84
|
+
if (primary === 'en') return 'en';
|
|
85
|
+
} catch {}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// CLI `--help` 출력에서 슬래시 명령/하위명령 best-effort 파싱 (UR-0021 3단계). 순수 문자열 처리.
|
|
90
|
+
function _parseSlashFromHelp(text, invoke = 'slash') {
|
|
91
|
+
const out = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
94
|
+
for (const raw of lines) {
|
|
95
|
+
const ln = raw.replace(/\x1b\[[0-9;]*m/g, ''); // ANSI 색상 제거
|
|
96
|
+
if (invoke === 'subcommand') {
|
|
97
|
+
const m = ln.match(/^\s{2,}([a-z][a-z0-9][\w-]*)\s{2,}(\S.*)$/);
|
|
98
|
+
if (m && !/^--/.test(m[1])) {
|
|
99
|
+
const cmd = m[1];
|
|
100
|
+
if (!seen.has(cmd) && cmd.length <= 24) { seen.add(cmd); out.push({ cmd, desc: m[2].trim().slice(0, 80) }); }
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const m = ln.match(/^\s*(\/[a-zA-Z][\w-]*)(?:\s+[-–:]?\s*(.*))?$/);
|
|
105
|
+
if (m) {
|
|
106
|
+
const cmd = m[1];
|
|
107
|
+
if (!seen.has(cmd) && cmd.length <= 24) { seen.add(cmd); out.push({ cmd, desc: (m[2] || '').trim().slice(0, 80) }); }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 1.9.283 (UR-0025 2단계): 권한 등급(permission tiers) 순수 로직 — capabilities/policy 공유.
|
|
114
|
+
const PERMISSION_TIERS = ['read-only', 'safe-write', 'project-write', 'shell-read', 'shell-write', 'git-write', 'network', 'publish'];
|
|
115
|
+
function _tierRank(t) { const i = PERMISSION_TIERS.indexOf(String(t || '')); return i < 0 ? PERMISSION_TIERS.length : i; }
|
|
116
|
+
// 명령/capability → 요구 등급 (순수 매핑)
|
|
117
|
+
function _requiredTier(cmd) {
|
|
118
|
+
const c = String(cmd || '').toLowerCase();
|
|
119
|
+
if (/release\s+publish|npm\s+publish|\bpublish\b/.test(c)) return 'publish';
|
|
120
|
+
if (/\bweb\b/.test(c)) return 'network';
|
|
121
|
+
if (/git\s+push|sync-main/.test(c)) return 'git-write';
|
|
122
|
+
if (/multi\s+--execute|dispatch\s+--write|--yolo|\bpc\b/.test(c)) return 'shell-write';
|
|
123
|
+
if (/agents\s+(list|quota|bench)|--run-tests/.test(c)) return 'shell-read';
|
|
124
|
+
if (/\binit\b|\badapter\b|update\s+--yes|\bmigrate\b/.test(c)) return 'project-write';
|
|
125
|
+
if (/state\s+(start|record|verify|handoff)|decision|lesson|plan\s+add|task\s+add|rule\s+add/.test(c)) return 'safe-write';
|
|
126
|
+
return 'read-only';
|
|
127
|
+
}
|
|
128
|
+
function _policyAllows(allowedTier, requiredTier) { return _tierRank(requiredTier) <= _tierRank(allowedTier); }
|
|
129
|
+
|
|
130
|
+
// 1.9.283: npm dist-tag 결정 (UR-0026) — latest(안정)/next(실험), 잘못된 형식은 latest.
|
|
131
|
+
function _resolveNpmTag(explicit, env) {
|
|
132
|
+
env = env || process.env;
|
|
133
|
+
const raw = String(explicit || env.LEERNESS_NPM_TAG || 'latest').trim().toLowerCase();
|
|
134
|
+
return /^[a-z][a-z0-9-]{0,38}$/.test(raw) ? raw : 'latest';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 1.9.283: .mcp.json 내용 (UR-0033) — leerness MCP 서버 등록.
|
|
138
|
+
function _mcpJsonContent() {
|
|
139
|
+
return JSON.stringify({ mcpServers: { leerness: { command: 'npx', args: ['leerness', 'mcp', 'serve'] } } }, null, 2) + '\n';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 1.9.283: run 레코드 빌더 (UR-0032) — GPT-5.5 권고 14필드. startedAt 주입 가능(테스트).
|
|
143
|
+
function _newRunRecord(opts = {}) {
|
|
144
|
+
return {
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
run_id: opts.run_id || null,
|
|
147
|
+
task_id: opts.task_id || null,
|
|
148
|
+
agent_name: opts.agent_name || null,
|
|
149
|
+
model_name: opts.model_name || null,
|
|
150
|
+
started_at: opts.started_at || new Date().toISOString(),
|
|
151
|
+
ended_at: opts.ended_at || null,
|
|
152
|
+
goal: opts.goal || '',
|
|
153
|
+
files_read: Array.isArray(opts.files_read) ? opts.files_read : [],
|
|
154
|
+
files_changed: Array.isArray(opts.files_changed) ? opts.files_changed : [],
|
|
155
|
+
commands_run: Array.isArray(opts.commands_run) ? opts.commands_run : [],
|
|
156
|
+
tests_run: Array.isArray(opts.tests_run) ? opts.tests_run : [],
|
|
157
|
+
errors: Array.isArray(opts.errors) ? opts.errors : [],
|
|
158
|
+
decisions: Array.isArray(opts.decisions) ? opts.decisions : [],
|
|
159
|
+
verification_result: opts.verification_result || null,
|
|
160
|
+
handoff_summary: opts.handoff_summary || null,
|
|
161
|
+
status: opts.status || 'in-progress'
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 1.9.443 (GPT-5.5 전략리뷰 §6.3/6.4, UR-0153): evidence-first 완료 게이트 — run-record 증거로 "완료 주장 가능" 여부 파생.
|
|
166
|
+
// 허용 조건: 변경 파일 존재 + 검증 실행(tests/commands) + 미해결 errors 0 + verification_result === 'pass'.
|
|
167
|
+
// verification 미실행/실패는 불허(증거 없는 완료 차단). reasons 로 불허 사유 명시. 순수 함수(저장 X, 읽을 때 계산).
|
|
168
|
+
function _completionClaimAllowed(rec) {
|
|
169
|
+
const r = rec || {};
|
|
170
|
+
const A = (x) => (Array.isArray(x) ? x : []);
|
|
171
|
+
const reasons = [];
|
|
172
|
+
if (A(r.files_changed).length === 0) reasons.push('no_files_changed');
|
|
173
|
+
if (A(r.tests_run).length === 0 && A(r.commands_run).length === 0) reasons.push('no_verification_run');
|
|
174
|
+
if (A(r.errors).length > 0) reasons.push('unresolved_errors');
|
|
175
|
+
const vr = String(r.verification_result || '').toLowerCase();
|
|
176
|
+
if (vr === 'fail') reasons.push('verification_failed');
|
|
177
|
+
else if (vr !== 'pass') reasons.push('not_verified');
|
|
178
|
+
return { allowed: reasons.length === 0, reasons };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 1.9.318 (UR-0025): 순수 HTML 파싱 유틸 (api-skill 문서 수집용) — fs/네트워크 의존 0, URL/regex 만 사용.
|
|
182
|
+
function _htmlToText(html) {
|
|
183
|
+
if (!html) return '';
|
|
184
|
+
return html
|
|
185
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
186
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
187
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
188
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
189
|
+
.replace(/<\/?(p|div|li|h[1-6]|tr|td|pre)[^>]*>/gi, '\n')
|
|
190
|
+
.replace(/<[^>]+>/g, ' ')
|
|
191
|
+
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
192
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
193
|
+
.replace(/[ \t]+/g, ' ').replace(/\n\s*\n\s*\n+/g, '\n\n').trim();
|
|
194
|
+
}
|
|
195
|
+
function _extractTitle(html) {
|
|
196
|
+
const m = (html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
197
|
+
if (!m) return '';
|
|
198
|
+
return _htmlToText(m[1]).slice(0, 200);
|
|
199
|
+
}
|
|
200
|
+
function _extractLinks(html, baseUrl, maxLinks) {
|
|
201
|
+
if (!html) return [];
|
|
202
|
+
const base = new URL(baseUrl);
|
|
203
|
+
const found = new Map();
|
|
204
|
+
const re = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
205
|
+
let m;
|
|
206
|
+
while ((m = re.exec(html)) !== null) {
|
|
207
|
+
let href = m[1];
|
|
208
|
+
if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:')) continue;
|
|
209
|
+
let abs;
|
|
210
|
+
try { abs = new URL(href, baseUrl).toString(); } catch { continue; }
|
|
211
|
+
const u = new URL(abs);
|
|
212
|
+
if (u.hostname !== base.hostname) continue; // same-domain only
|
|
213
|
+
if (abs === baseUrl) continue;
|
|
214
|
+
if (found.has(abs)) continue;
|
|
215
|
+
const text = _htmlToText(m[2]).slice(0, 120);
|
|
216
|
+
found.set(abs, { url: abs, text });
|
|
217
|
+
if (found.size >= (maxLinks || 10)) break;
|
|
218
|
+
}
|
|
219
|
+
return Array.from(found.values());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 1.9.324 (UR-0025): 순수 메모리 MD 파서 — 코드펜스(```md 템플릿 예시) 제거 후 날짜 블록(### YYYY-MM-DD) 카운트/추출.
|
|
223
|
+
// count drift(템플릿 오집계) 방지의 단일 진실소스. decisions/lessons 카운터가 공유.
|
|
224
|
+
function _countDatedBlocks(text) {
|
|
225
|
+
const cleaned = String(text || '').replace(/^```[^\n]*\n[\s\S]*?\n```\s*$/gm, ''); // 코드펜스(템플릿) 제거
|
|
226
|
+
return (cleaned.match(/^### \d{4}-\d{2}-\d{2}/gm) || []).length;
|
|
227
|
+
}
|
|
228
|
+
function _extractDecisionBlocks(text) {
|
|
229
|
+
// 줄 시작의 ```부터 줄 시작의 ```까지를 코드블록으로 인식 (인라인 백틱 무시)
|
|
230
|
+
const cleaned = String(text || '').replace(/^```[^\n]*\n[\s\S]*?\n```\s*$/gm, '');
|
|
231
|
+
return cleaned.split(/\n(?=### )/).filter(b =>
|
|
232
|
+
b.startsWith('### ') && !/^### (Template|템플릿)\b/.test(b.trim())
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 1.9.325 (UR-0025): 순수 intent 분류 — 사용자 텍스트의 precise/broad 신호로 의도 추정 (fs/상태 의존 0).
|
|
237
|
+
function _classifyIntent(text) {
|
|
238
|
+
if (!text || typeof text !== 'string') return { intent: 'default', signals: [] };
|
|
239
|
+
const signals = [];
|
|
240
|
+
// precise 신호: "정확히 / 그것만 / 그대로 / only / just / 만"
|
|
241
|
+
const preciseKws = ['정확히', '그것만', '그대로', 'only', 'just only', '말한대로', '말한 그대로'];
|
|
242
|
+
for (const kw of preciseKws) {
|
|
243
|
+
if (text.toLowerCase().includes(kw.toLowerCase())) signals.push({ kind: 'precise', match: kw });
|
|
244
|
+
}
|
|
245
|
+
// broad 신호: "기본 / 포괄적 / 등등 / 다양한 / 전체 / 기본적인 / etc / overall"
|
|
246
|
+
const broadKws = ['기본', '포괄적', '등등', '다양한', '전체', '기본적인', 'etc', 'overall', '필요한', '관련', 'comprehensive', 'including'];
|
|
247
|
+
for (const kw of broadKws) {
|
|
248
|
+
if (text.toLowerCase().includes(kw.toLowerCase())) signals.push({ kind: 'broad', match: kw });
|
|
249
|
+
}
|
|
250
|
+
const preciseCount = signals.filter(s => s.kind === 'precise').length;
|
|
251
|
+
const broadCount = signals.filter(s => s.kind === 'broad').length;
|
|
252
|
+
let intent;
|
|
253
|
+
if (preciseCount > broadCount && preciseCount >= 1) intent = 'precise';
|
|
254
|
+
else if (broadCount >= 1) intent = 'broad';
|
|
255
|
+
else intent = 'default';
|
|
256
|
+
return { intent, signals, preciseCount, broadCount };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 1.9.326 (UR-0025): 순수 문자열/셸/env 유틸.
|
|
260
|
+
// 코드펜스(```) 중립화 — 임베딩 텍스트가 외부 마크다운을 깨지 않게. (``` → ''', 인라인 백틱 보존)
|
|
261
|
+
function _sanitizeFences(s) { return String(s || '').replace(/```+/g, "'''"); }
|
|
262
|
+
// shell:true spawn 인자 셸-안전 인용 — POSIX(sh) single-quote / Windows(cmd) double-quote + inner " 이스케이프.
|
|
263
|
+
function _shellQuoteArg(s) {
|
|
264
|
+
s = String(s == null ? '' : s);
|
|
265
|
+
if (process.platform === 'win32') return '"' + s.replace(/"/g, '""') + '"';
|
|
266
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
267
|
+
}
|
|
268
|
+
// Windows PowerShell 실행 env 감지 — pwsh 6/7 신뢰 마커(POWERSHELL_DISTRIBUTION_CHANNEL / pwsh 전용 경로)만 판별(ps5.1 자동판별 안 함).
|
|
269
|
+
function _detectPwshFromEnv(e) {
|
|
270
|
+
e = e || process.env;
|
|
271
|
+
const channel = e.POWERSHELL_DISTRIBUTION_CHANNEL || '';
|
|
272
|
+
const pmp = e.PSModulePath || '';
|
|
273
|
+
if (channel || /[\\/]PowerShell[\\/][67][\\/]/i.test(pmp) || /Documents[\\/]+PowerShell[\\/]/i.test(pmp)) {
|
|
274
|
+
return { isPowerShell: true, version: '7', edition: 'Core' };
|
|
275
|
+
}
|
|
276
|
+
return { isPowerShell: false, version: null, edition: null };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 1.9.327 (UR-0025): 순수 TZ/날짜 포맷 — ISO UTC 저장 유지, 표시 시 local 변환 (env LEERNESS_TZ / 시스템 tz / Asia/Seoul fallback).
|
|
280
|
+
function _getLocalTz() {
|
|
281
|
+
if (process.env.LEERNESS_TZ) return process.env.LEERNESS_TZ;
|
|
282
|
+
try {
|
|
283
|
+
const sys = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
284
|
+
if (sys && sys !== 'UTC') return sys;
|
|
285
|
+
} catch {}
|
|
286
|
+
return 'Asia/Seoul';
|
|
287
|
+
}
|
|
288
|
+
function _formatLocal(iso, opts) {
|
|
289
|
+
if (!iso) return '?';
|
|
290
|
+
opts = opts || {};
|
|
291
|
+
const tz = opts.tz || _getLocalTz();
|
|
292
|
+
try {
|
|
293
|
+
const d = typeof iso === 'string' ? new Date(iso) : iso;
|
|
294
|
+
if (isNaN(d.getTime())) return String(iso);
|
|
295
|
+
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
296
|
+
timeZone: tz,
|
|
297
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
298
|
+
hour: '2-digit', minute: '2-digit',
|
|
299
|
+
hour12: false
|
|
300
|
+
});
|
|
301
|
+
const parts = fmt.formatToParts(d);
|
|
302
|
+
const get = (t) => (parts.find(p => p.type === t) || {}).value || '';
|
|
303
|
+
const date = `${get('year')}-${get('month')}-${get('day')}`;
|
|
304
|
+
const time = `${get('hour')}:${get('minute')}`;
|
|
305
|
+
const tzShort = tz === 'Asia/Seoul' ? 'KST' : tz === 'Asia/Tokyo' ? 'JST' : tz === 'UTC' ? 'UTC' : tz.split('/').pop().slice(0, 3);
|
|
306
|
+
return opts.dateOnly ? date : `${date} ${time} ${tzShort}`;
|
|
307
|
+
} catch { return String(iso); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 1.9.328 (UR-0025): 순수 문자열 유틸 — 절단(말줄임표) / 콤마 리스트 분할.
|
|
311
|
+
function _truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
312
|
+
function _splitList(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
|
|
313
|
+
|
|
314
|
+
// 1.9.329 (UR-0025): 순수 roadmap MD 파서 — 상태 정규화 / 마일스톤·토큰 추출 (fs 의존 0).
|
|
315
|
+
function _roadmapMapStatus(s) {
|
|
316
|
+
s = String(s || '').toLowerCase();
|
|
317
|
+
if (s === 'done' || s === 'in-progress' || s === 'on-hold' || s === 'waiting' || s === 'incomplete' || s === 'blocked' || s === 'dropped') return s;
|
|
318
|
+
if (s === 'planned' || s === 'requested') return 'planned';
|
|
319
|
+
return 'planned';
|
|
320
|
+
}
|
|
321
|
+
function _roadmapParseMilestones(text) {
|
|
322
|
+
const s = String(text || '');
|
|
323
|
+
const out = [];
|
|
324
|
+
// 1.9.352 (UR-0068 외부리뷰): 다음 milestone 직전까지 block 한정 — 이전 구현은 slice(m.index) 로 다음 milestone 의 Status/Progress 를 누출했음
|
|
325
|
+
const matches = [...s.matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)];
|
|
326
|
+
for (let i = 0; i < matches.length; i++) {
|
|
327
|
+
const m = matches[i];
|
|
328
|
+
const end = i + 1 < matches.length ? matches[i + 1].index : s.length;
|
|
329
|
+
const block = s.slice(m.index, end);
|
|
330
|
+
const sm = block.match(/^Status:\s*(\S+)/m);
|
|
331
|
+
const pm = block.match(/^Progress:\s*(\d+)%/m);
|
|
332
|
+
out.push({ id: m[1], title: m[2].trim(), status: sm ? sm[1] : 'planned', progress: pm ? parseInt(pm[1], 10) : 0 });
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
function _roadmapParseTokens(text) {
|
|
337
|
+
const tokens = {};
|
|
338
|
+
for (const line of String(text || '').split('\n')) {
|
|
339
|
+
const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
|
|
340
|
+
if (!m) continue;
|
|
341
|
+
const key = m[1].trim(), val = m[2].trim();
|
|
342
|
+
if (!key || !val || key === 'Token' || /^-+$/.test(key) || val === 'Value' || /\(실제 값으로 업데이트\)/.test(val)) continue;
|
|
343
|
+
if (val.length > 80) continue;
|
|
344
|
+
tokens[key] = val;
|
|
345
|
+
}
|
|
346
|
+
return tokens;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 1.9.330 (UR-0025): project-brief 필드 config(순수 데이터) + 채움 카운트 derivation.
|
|
350
|
+
const _BRIEF_FIELDS = [
|
|
351
|
+
{ key: 'intro', h: 'Intro', label: '소개', flag: 'intro', multi: false },
|
|
352
|
+
{ key: 'purpose', h: 'Purpose', label: '목적', flag: 'purpose', multi: false },
|
|
353
|
+
{ key: 'problem', h: 'Problem', label: '해결 문제', flag: 'problem', multi: false },
|
|
354
|
+
{ key: 'features', h: 'Features', label: '핵심 기능', flag: 'features', multi: true },
|
|
355
|
+
{ key: 'stack', h: 'Tech Stack', label: '기술 스택', flag: 'stack', multi: true },
|
|
356
|
+
{ key: 'architecture', h: 'Architecture', label: '아키텍처', flag: 'architecture', multi: false },
|
|
357
|
+
{ key: 'users', h: 'Users', label: '사용자', flag: 'users', multi: true },
|
|
358
|
+
{ key: 'success', h: 'Success Criteria', label: '성공 기준', flag: 'success', multi: true },
|
|
359
|
+
{ key: 'nonGoals', h: 'Non-Goals', label: '비목표', flag: 'non-goals', multi: true },
|
|
360
|
+
{ key: 'currentState', h: 'Current State', label: '현재 상태', flag: 'current-state', multi: false },
|
|
361
|
+
];
|
|
362
|
+
function _briefFilled(brief) { return _BRIEF_FIELDS.filter(f => (f.multi ? (brief[f.key] && brief[f.key].length) : brief[f.key])).length; }
|
|
363
|
+
// 1.9.331 (UR-0025): project-brief 텍스트 빌더 (순수) — README 개요 블록 / 복사용 청사진. VERSION 은 인자로 주입.
|
|
364
|
+
const BRIEF_START = '<!-- leerness:project-brief:start -->';
|
|
365
|
+
const BRIEF_END = '<!-- leerness:project-brief:end -->';
|
|
366
|
+
function _briefReadmeBlock(brief) {
|
|
367
|
+
const L = [BRIEF_START, '## 프로젝트 개요', ''];
|
|
368
|
+
if (brief.intro) L.push(brief.intro, '');
|
|
369
|
+
if (brief.purpose) L.push(`**목적**: ${brief.purpose}`, '');
|
|
370
|
+
if (brief.problem) L.push(`**해결 문제**: ${brief.problem}`, '');
|
|
371
|
+
if (brief.features && brief.features.length) { L.push('**핵심 기능**'); brief.features.forEach(x => L.push(`- ${x}`)); L.push(''); }
|
|
372
|
+
if (brief.stack && brief.stack.length) L.push(`**기술 스택**: ${brief.stack.join(', ')}`, '');
|
|
373
|
+
if (brief.directionHistory && brief.directionHistory.length) { L.push('**최근 개발 방향 변경**'); brief.directionHistory.slice(-3).forEach(x => L.push(`- ${x}`)); L.push(''); }
|
|
374
|
+
if (_briefFilled(brief) === 0) L.push('_아직 개요 미입력 — `leerness brief set --intro "..." --purpose "..."` 로 작성._', '');
|
|
375
|
+
L.push('<sub>이 섹션은 `leerness brief` 로 관리됩니다. 전체 청사진(복사용): `leerness brief export`.</sub>', BRIEF_END);
|
|
376
|
+
return L.join('\n');
|
|
377
|
+
}
|
|
378
|
+
function _briefBlueprint(brief, version) {
|
|
379
|
+
const L = [`# ${brief.project} — 프로젝트 청사진 (Blueprint)`,
|
|
380
|
+
`> 이 문서만으로 프로젝트를 기초부터 재구성할 수 있도록 작성. \`leerness brief export\` 생성 (leerness v${version || '?'}).`, ''];
|
|
381
|
+
const sec = (h, v, multi) => { if (multi ? (v && v.length) : v) { L.push(`## ${h}`, multi ? v.map(x => `- ${x}`).join('\n') : v, ''); } };
|
|
382
|
+
sec('소개 (Intro)', brief.intro); sec('목적 (Purpose)', brief.purpose); sec('해결 문제 (Problem)', brief.problem);
|
|
383
|
+
sec('핵심 기능 (Features)', brief.features, true); sec('기술 스택 (Tech Stack)', brief.stack, true);
|
|
384
|
+
sec('아키텍처 (Architecture)', brief.architecture); sec('사용자 (Users)', brief.users, true);
|
|
385
|
+
sec('성공 기준 (Success Criteria)', brief.success, true); sec('비목표 (Non-Goals)', brief.nonGoals, true);
|
|
386
|
+
sec('현재 상태 (Current State)', brief.currentState);
|
|
387
|
+
sec('개발 방향 이력 (Direction History)', brief.directionHistory, true);
|
|
388
|
+
L.push('---', '## 신규 프로젝트 시작 가이드', '', '1. 위 소개·목적·기능·아키텍처·스택을 신규 레포의 계획으로 복사.', '2. `leerness init .` 후 이 파일을 `.harness/project-brief.md` 로 복사하거나 `leerness brief set` 으로 재입력.', '3. Features 를 `leerness plan add` / `leerness task add` 로 분해.', '');
|
|
389
|
+
return L.join('\n');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 1.9.332 (UR-0025): 순수 lessons.md 파서 — 블록(### 날짜)→엔트리 {date, text, tag}. 필터는 호출측.
|
|
393
|
+
function _parseLessonEntries(text) {
|
|
394
|
+
const out = [];
|
|
395
|
+
for (const block of String(text || '').split(/\n(?=### )/)) {
|
|
396
|
+
if (!block.startsWith('### ')) continue;
|
|
397
|
+
const dateMatch = block.match(/^### (\d{4}-\d{2}-\d{2}[^\n]*)/);
|
|
398
|
+
const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
|
|
399
|
+
const tagMatch = block.match(/- Tag:[ \t]*(.+)/);
|
|
400
|
+
if (!lessonMatch) continue;
|
|
401
|
+
out.push({ date: dateMatch ? dateMatch[1].trim() : null, text: lessonMatch[1].trim(), tag: tagMatch ? tagMatch[1].trim() : null });
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// UR-0058: canonical lessons 객체 배열 → lessons.md projection. _parseLessonEntries 와 round-trip 안전.
|
|
407
|
+
function _renderLessonsMd(lessons) {
|
|
408
|
+
const preamble = '# Lessons (1.9.112)\n\n과거 실수/통찰/패턴 영구 기록 — handoff 자동 회수와 통합.\n';
|
|
409
|
+
const body = (lessons || []).map(l =>
|
|
410
|
+
// 1.9.402 (UR-0108): text/tag 개행 → 공백(MD projection 라인 위조 차단). canonical JSON 은 raw 유지.
|
|
411
|
+
`\n### ${_lineSafe(l.date)}\n- Lesson: ${_lineSafe(l.text)}\n${l.tag ? `- Tag: ${_lineSafe(l.tag)}\n` : ''}`
|
|
412
|
+
).join('');
|
|
413
|
+
return preamble + body;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 1.9.341 (UR-0025 심층): 내장 스킬 catalog → _source:'builtin' 부여 맵 (skillpack fallback 순수 변환).
|
|
417
|
+
function _withBuiltinSource(catalog) {
|
|
418
|
+
const out = {};
|
|
419
|
+
for (const [k, v] of Object.entries(catalog || {})) out[k] = { ...v, _source: 'builtin' };
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 1.9.345 (UR-0025 심층): HTML escape (roadmap.html 등 출력 인젝션 방지) — 순수, null-safe.
|
|
424
|
+
function _esc(s) {
|
|
425
|
+
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 1.9.346 (UR-0025 심층): roadmap.html :root CSS 변수 빌더 — designTokens/cssVariables 주입, 순수(모듈 의존 0).
|
|
429
|
+
function _roadmapTokenStyles(designTokens, cssVariables) {
|
|
430
|
+
const dt = designTokens || {}, cv = cssVariables || {};
|
|
431
|
+
const vars = {};
|
|
432
|
+
const map = [
|
|
433
|
+
['color.primary', 'color-primary', 'lr-primary'], ['color.surface', 'color-surface', 'lr-surface'],
|
|
434
|
+
['color.text', 'color-text', 'lr-text'], ['color.muted', 'color-muted', 'lr-muted'],
|
|
435
|
+
['space.1', 'space-1', 'lr-space-1'], ['space.2', 'space-2', 'lr-space-2'],
|
|
436
|
+
['space.3', 'space-3', 'lr-space-3'], ['space.4', 'space-4', 'lr-space-4'],
|
|
437
|
+
['radius', 'radius', 'lr-radius']
|
|
438
|
+
];
|
|
439
|
+
for (const [ds, css, vn] of map) { const v = cv[css] || dt[ds]; if (v) vars[vn] = v; }
|
|
440
|
+
for (const [k, v] of Object.entries(cv)) if (!vars[`lr-${k}`]) vars[`lr-${k}`] = v;
|
|
441
|
+
if (!vars['lr-card-bg']) vars['lr-card-bg'] = vars['lr-surface'] || '#ffffff';
|
|
442
|
+
if (!vars['lr-edge']) vars['lr-edge'] = vars['lr-muted'] || '#cbd5e1';
|
|
443
|
+
if (!vars['lr-page-bg']) vars['lr-page-bg'] = '#f8fafc';
|
|
444
|
+
// 1.9.350 (UR-0060/0061 외부리뷰): CSS 값 살균 — whitelist 로 } < > ; { @ : / 등 제거(:root 규칙 breakout + </style> HTML 탈출 차단). 색상/길이 형식은 보존.
|
|
445
|
+
const _safeCss = v => String(v == null ? '' : v).replace(/[^#a-zA-Z0-9(),.%\s_-]/g, '').slice(0, 80);
|
|
446
|
+
return ':root {\n' + Object.entries(vars).map(([k, v]) => ` --${k}: ${_safeCss(v)};`).join('\n') + '\n }';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 1.9.347 (UR-0025 심층): SKILL.md frontmatter 파서 — { meta, body }, BOM-aware (Windows Notepad 호환). 순수.
|
|
450
|
+
function _parseSkillMd(text) {
|
|
451
|
+
// 1.9.408 (8번째 버그헌트, UR-0112): BOM strip + CRLF/CR→LF 정규화.
|
|
452
|
+
// 기존 버그: frontmatter 값 정규식 (.+)$ 의 '.'은 CR(\r)을 매칭 못 해 'name: x\r' 라인이 통째로 실패 → CRLF SKILL.md(Windows/Notepad)의 meta 전체 소실 → skill install "name 필수" 실패.
|
|
453
|
+
const cleaned = String(text || '').replace(/^/, '').replace(/\r\n?/g, '\n'); // BOM strip (U+FEFF) + 줄바꿈 정규화
|
|
454
|
+
const m = cleaned.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
455
|
+
if (!m) return { meta: {}, body: cleaned };
|
|
456
|
+
const meta = {};
|
|
457
|
+
for (const line of m[1].split('\n')) {
|
|
458
|
+
const km = line.match(/^([a-zA-Z_-]+):\s*(.+)$/);
|
|
459
|
+
if (km) meta[km[1].trim()] = km[2].trim().replace(/^["']|["']$/g, '');
|
|
460
|
+
}
|
|
461
|
+
return { meta, body: m[2] };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 1.9.333 (UR-0025 심층): 순수 플랫폼 제약 매칭 — catalog + 텍스트 → 매칭 플랫폼/제약/제안 (fs 의존 0, catalog 주입).
|
|
465
|
+
function _matchConstraints(catalog, text) {
|
|
466
|
+
if (!text || typeof text !== 'string' || !catalog || !catalog.platforms) return { matched: [], suggestions: [] };
|
|
467
|
+
const lower = text.toLowerCase();
|
|
468
|
+
const matched = [];
|
|
469
|
+
for (const [pid, plat] of Object.entries(catalog.platforms)) {
|
|
470
|
+
const aliases = plat.aliases || [];
|
|
471
|
+
const hit = aliases.find(a => lower.includes(a.toLowerCase()));
|
|
472
|
+
if (hit) matched.push({ platform: pid, matchedAlias: hit, docs: plat.docs, constraints: plat.constraints });
|
|
473
|
+
}
|
|
474
|
+
const suggestions = [];
|
|
475
|
+
const generic = /\bapi\b|연동|integration|호출|rate|limit|quota|webhook/i.test(text);
|
|
476
|
+
if (generic && matched.length === 0) {
|
|
477
|
+
suggestions.push('일반적 API 연동 키워드 감지 — leerness constraints list 로 사전 등록된 플랫폼 catalog 확인 권장');
|
|
478
|
+
}
|
|
479
|
+
return { matched, suggestions, totalPlatforms: Object.keys(catalog.platforms).length };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 1.9.333 패턴 적용: 순수 도메인 매칭 — catalog + 텍스트 → 첫 매칭 domain/alias/components (fs 의존 0, catalog 주입).
|
|
483
|
+
function _matchDomain(catalog, text) {
|
|
484
|
+
if (!text || typeof text !== 'string' || !catalog || !catalog.domains) return { domain: null, alias: null };
|
|
485
|
+
const lower = text.toLowerCase();
|
|
486
|
+
for (const [domain, info] of Object.entries(catalog.domains)) {
|
|
487
|
+
for (const a of info.aliases || []) {
|
|
488
|
+
if (lower.includes(a.toLowerCase())) {
|
|
489
|
+
return { domain, alias: a, components: info.components };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return { domain: null, alias: null };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 1.9.335 (UR-0025 심층): LSP 서브시스템 — 순수 언어 감지 (파일 확장자 → 언어)
|
|
497
|
+
function _detectLspLang(file) {
|
|
498
|
+
const ext = ((file || '').match(/\.[a-zA-Z0-9]+$/) || [''])[0].toLowerCase();
|
|
499
|
+
if (/^\.(py|pyw|pyi)$/.test(ext)) return 'python';
|
|
500
|
+
if (ext === '.go') return 'go';
|
|
501
|
+
if (ext === '.rs') return 'rust';
|
|
502
|
+
if (/^\.(java|kt|scala)$/.test(ext)) return 'java';
|
|
503
|
+
if (/^\.(ts|tsx|js|jsx|mjs|cjs)$/.test(ext)) return 'javascript';
|
|
504
|
+
return 'javascript'; // default — 기본 JS 패턴 (.txt/.md 등 미지원 확장자)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 1.9.335 (UR-0025 심층): LSP 서브시스템 — 순수 정규식 심볼 매처 (catalog 주입, constraints/domain 패턴 동일)
|
|
508
|
+
// catalog: { <lang>: [{ re, kind }, ...] } · content: 소스 텍스트 · lang: 언어 키
|
|
509
|
+
function _matchLspSymbols(catalog, content, lang) {
|
|
510
|
+
const symbols = [];
|
|
511
|
+
if (!catalog || typeof content !== 'string') return symbols;
|
|
512
|
+
const lines = content.split(/\r?\n/);
|
|
513
|
+
const patterns = catalog[lang || 'javascript'] || catalog.javascript || [];
|
|
514
|
+
lines.forEach((line, idx) => {
|
|
515
|
+
for (const p of patterns) {
|
|
516
|
+
const m = line.match(p.re);
|
|
517
|
+
// 키워드 false-positive 제거 (예: java method 정규식이 if(/for( 등에 매치되는 경우)
|
|
518
|
+
if (m && !/^(if|for|while|switch|catch|return|throw|new)$/.test(m[1])) {
|
|
519
|
+
symbols.push({ name: m[1], kind: p.kind, line: idx + 1 });
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
return symbols;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 1.9.27: URL/메서드 단위 매핑 — evidence에서 "POST /users" 같은 구체 경로를 추출하고 코드에 같은 경로 존재 확인
|
|
528
|
+
function _extractUrlClaims(evidence) {
|
|
529
|
+
const claims = [];
|
|
530
|
+
// "POST /users" / "GET /api/v1/items" 등
|
|
531
|
+
const re = /\b(GET|POST|PUT|DELETE|PATCH)\s+(\/[\w\-\/]*)/gi;
|
|
532
|
+
let m;
|
|
533
|
+
while ((m = re.exec(evidence)) !== null) {
|
|
534
|
+
claims.push({ method: m[1].toUpperCase(), path: m[2] });
|
|
535
|
+
}
|
|
536
|
+
return claims;
|
|
537
|
+
}
|
|
538
|
+
function _verifyUrlClaim(claim, codeText) {
|
|
539
|
+
// claim.path 가 코드에 등장해야 함 (fetch('https://.../users') 또는 라우트 정의 'POST /users')
|
|
540
|
+
if (!claim.path || claim.path.length < 2) return true;
|
|
541
|
+
// path를 그대로 검색 (URL 또는 라우트 정의)
|
|
542
|
+
const escaped = claim.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
543
|
+
const re = new RegExp(escaped, 'i');
|
|
544
|
+
return re.test(codeText);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function _detectOptimism(patterns, evidence, codeText) {
|
|
548
|
+
// 각 패턴 검사: evidence에 주장 있고 코드에 흔적 없으면 의심
|
|
549
|
+
const suspects = [];
|
|
550
|
+
if (!Array.isArray(patterns)) return suspects;
|
|
551
|
+
for (const p of patterns) {
|
|
552
|
+
if (p.evidenceRe.test(evidence) && !p.codeRe.test(codeText)) {
|
|
553
|
+
suspects.push({ kind: p.kind, label: p.label, severity: 'high' });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// 1.9.27: URL/메서드 단위 매핑 — API 패턴에선 통과해도 구체 경로가 코드에 없으면 추가 의심
|
|
557
|
+
const urlClaims = _extractUrlClaims(evidence);
|
|
558
|
+
for (const claim of urlClaims) {
|
|
559
|
+
if (!_verifyUrlClaim(claim, codeText)) {
|
|
560
|
+
suspects.push({
|
|
561
|
+
kind: 'URL',
|
|
562
|
+
label: `구체 경로 "${claim.method} ${claim.path}" 코드에 미발견`,
|
|
563
|
+
severity: 'medium',
|
|
564
|
+
claim
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return suspects;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 1.9.27: 신뢰도 점수 (0=완전 의심, 1=신뢰)
|
|
572
|
+
// 1.9.28: high suspect 단일 케이스 floor 0.15 — 단일 의심도 정량 차등 가능하게
|
|
573
|
+
function _computeConfidence(patterns, evidence, codeText) {
|
|
574
|
+
if (!Array.isArray(patterns)) return 1.0;
|
|
575
|
+
const suspects = _detectOptimism(patterns, evidence, codeText);
|
|
576
|
+
const high = suspects.filter(s => s.severity === 'high').length;
|
|
577
|
+
const medium = suspects.filter(s => s.severity === 'medium').length;
|
|
578
|
+
// 가중치: high 1.0 / medium 0.5
|
|
579
|
+
const totalPenalty = high * 1.0 + medium * 0.5;
|
|
580
|
+
// 패턴 검사로 발견된 evidence 주장이 많을수록 신뢰도 산정 base 변경
|
|
581
|
+
const evidenceClaims = patterns.filter(p => p.evidenceRe.test(evidence)).length + _extractUrlClaims(evidence).length;
|
|
582
|
+
if (evidenceClaims === 0) return 1.0; // 외부 작용 주장 자체가 없으면 신뢰 1.0
|
|
583
|
+
let confidence = Math.max(0, 1 - totalPenalty / evidenceClaims);
|
|
584
|
+
// 1.9.28: single high suspect에서 confidence 0.0이 일률적 → severity 기반 floor 적용
|
|
585
|
+
if (suspects.length > 0 && high > 0 && confidence < 0.15) {
|
|
586
|
+
// 의심 발견은 명확하지만 0보다는 명시적 신호로
|
|
587
|
+
confidence = 0.15;
|
|
588
|
+
}
|
|
589
|
+
return Math.round(confidence * 100) / 100;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 1.9.337 (UR-0025 심층): persona catalog → 요약 목록 (id/name/description) 순수 변환 (list 명령 JSON 경로)
|
|
593
|
+
function _personaSummaries(catalog) {
|
|
594
|
+
return Object.values(catalog || {}).map(p => ({ id: p.id, name: p.name, description: p.description }));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 1.9.338 (UR-0025 심층): i18n 순수 조회 — strings catalog 주입, key → lang 값 (fallback: ko → key 자체)
|
|
598
|
+
function _translate(strings, key, lang) {
|
|
599
|
+
const entry = strings && strings[key];
|
|
600
|
+
if (!entry) return key;
|
|
601
|
+
return entry[lang || 'ko'] || entry.ko || key;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 1.9.339 (UR-0053): decision MD 블록(문자열) → 정규 객체 (canonical 스키마). list/load 단일 파서.
|
|
605
|
+
function _parseDecisionBlock(block) {
|
|
606
|
+
const titleMatch = String(block || '').match(/^### (.+)$/m);
|
|
607
|
+
const titleLine = titleMatch ? titleMatch[1].trim() : '';
|
|
608
|
+
const dateTitle = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
|
|
609
|
+
const g = re => { const m = String(block || '').match(re); const v = m ? m[1].trim() : null; return v || null; }; // 빈 값 → null 정규화 (render↔parse round-trip 멱등)
|
|
610
|
+
return {
|
|
611
|
+
date: dateTitle ? dateTitle[1] : null,
|
|
612
|
+
title: dateTitle ? dateTitle[2].trim() : titleLine,
|
|
613
|
+
decision: g(/- Decision:[ \t]*(.+)/),
|
|
614
|
+
reason: g(/- Reason:[ \t]*(.+)/),
|
|
615
|
+
alternatives: g(/- Alternatives:[ \t]*(.+)/),
|
|
616
|
+
impact: g(/- Impact:[ \t]*(.+)/)
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 1.9.339 (UR-0053): decisions.md 본문 → canonical 객체 배열 (template/code 블록 제외, title 있는 것만).
|
|
621
|
+
function _decisionsFromMd(text) {
|
|
622
|
+
return _extractDecisionBlocks(text).map(_parseDecisionBlock).filter(d => d.title);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 1.9.339 (UR-0053): canonical 객체 배열 → decisions.md projection (init template preamble 보존, round-trip 안전).
|
|
626
|
+
function _renderDecisionsMd(decisions) {
|
|
627
|
+
// preamble 의 코드펜스(```)는 single-quote 문자열로 안전 처리 (template literal 충돌 회피)
|
|
628
|
+
const preamble = '# Decisions\n\n## Template (예시 — 실제 결정은 아래 코드블록 밖에 추가)\n\n'
|
|
629
|
+
+ '```md\n### YYYY-MM-DD — Decision 제목\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n```\n';
|
|
630
|
+
const body = (decisions || []).map(d => {
|
|
631
|
+
// 1.9.402 (UR-0108): 필드 개행 → 공백(MD projection '### '/'- field:' 라인 위조 블록 주입 차단). canonical JSON 은 raw 유지.
|
|
632
|
+
const head = d.date ? `${_lineSafe(d.date)} — ${_lineSafe(d.title)}` : _lineSafe(d.title);
|
|
633
|
+
return `\n### ${head}\n- Decision: ${_lineSafe(d.decision || '')}\n- Reason: ${_lineSafe(d.reason || '')}\n- Alternatives: ${_lineSafe(d.alternatives || '')}\n- Impact: ${_lineSafe(d.impact || '')}\n`;
|
|
634
|
+
}).join('');
|
|
635
|
+
return preamble + body;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 1.9.365 (외부리뷰 CV-6/UR-0081): 시크릿 스캐너 오탐(FP) 억제 — 명백한 placeholder/예시 값은 시크릿 아님.
|
|
639
|
+
// assignment 패턴(secret/api_key = VALUE)의 VALUE 에만 적용 (provider 형식 키엔 미적용 → FN 방지).
|
|
640
|
+
function _isPlaceholderSecret(value) {
|
|
641
|
+
if (value == null) return true;
|
|
642
|
+
let v = String(value).trim().replace(/^["']|["']$/g, '').trim().toLowerCase();
|
|
643
|
+
if (!v) return true;
|
|
644
|
+
// 전체가 placeholder 토큰
|
|
645
|
+
if (/^(?:x{3,}|\*{3,}|\.{3,}|-+|0+|1234567890?|12345678|abc123|secret|password|passwd|changeme|change[-_]me|replace[-_]?me|placeholder|example|examples?|sample|dummy|test|testing|foo|bar|baz|tbd|todo|none|null|undefined|nil|empty|redacted|hidden|value|string|here)$/.test(v)) return true;
|
|
646
|
+
// 1.9.405 (8번째 버그헌트 회귀수정, UR-0109): placeholder 단어 신호를 entropy 가드보다 먼저 검사.
|
|
647
|
+
// 1.9.401 회귀: 긴 서술형 placeholder('your-super-secret-api-key-example-value')가 고엔트로피(영숫자24+ & 고유12+)를 넘어 실키로 오탐(FP).
|
|
648
|
+
// → placeholder 마커 단어가 있으면 entropy 가드 무시하고 placeholder 로 판정. 실키 prefix(sk-/AKIA 등)는 마커보다 우선(FN 방지).
|
|
649
|
+
const alnum = v.replace(/[^a-z0-9]/g, '');
|
|
650
|
+
const distinct = new Set(alnum).size;
|
|
651
|
+
const hasMarker = v.includes('example') || v.includes('placeholder') || v.includes('change-me') || v.includes('changeme') || v.includes('replace-me') || v.includes('your-') || v.includes('your_') || v.includes('my-secret') || v.includes('xxxx') || v.includes('<') || v.includes('${') || v.includes('{{');
|
|
652
|
+
const hasRealPrefix = /^(?:sk-|sk-proj-|pk_|rk_|akia|ghp_|gho_|ghs_|ghr_|github_pat_|xox[baprs]-|aiza|ya29\.|glpat-|-----begin)/.test(v);
|
|
653
|
+
// 1.9.436 (11th 외부평가 Opus P3): prefix 가 있어도 본문이 동일문자 8+연속(AKIAXXXX…/…00000000…)이면 명백한 더미 → placeholder. 실키는 고엔트로피라 무영향.
|
|
654
|
+
if (/(.)\1{7,}/.test(alnum)) return true;
|
|
655
|
+
// 1.10.1 (12th 외부평가 Opus P3, UR-0144): 'example' 로 끝나면(접미사) placeholder — AWS 공식 예제키 AKIAIOSFODNN7EXAMPLE 등.
|
|
656
|
+
// 중간에 'example' 이 있는 실키(sk-EXAMPLEab12…, sk-proj-realKEYexample…)는 접미사 아니라 미해당 → 기존 FN 정책(UR-0105) 보존. 실키는 'example' 로 끝날 확률 0.
|
|
657
|
+
if (/example$/.test(v)) return true;
|
|
658
|
+
// 실키 prefix → 항상 실키(마커 무시). 그 외 마커 단어 있으면 placeholder(고엔트로피여도). prefix 없고 마커 없고 고엔트로피 → 실키.
|
|
659
|
+
if (hasRealPrefix) return false;
|
|
660
|
+
if (hasMarker) return true;
|
|
661
|
+
if (alnum.length >= 24 && distinct >= 12) return false; // prefix·마커 없는 고엔트로피 = 실키
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
// 1.9.365 (외부리뷰 CV-6/UR-0081): unquoted assignment 값이 '시크릿스러운지' 판정 — 코드 식별자 오탐 억제용.
|
|
665
|
+
// 숫자 포함 8+ 또는 24+ 만 시크릿 후보 (camelCase 식별자 같은 무-숫자 단어는 제외).
|
|
666
|
+
function _looksSecretLike(value) {
|
|
667
|
+
const v = String(value || '');
|
|
668
|
+
if (!v) return false;
|
|
669
|
+
return (/\d/.test(v) && v.length >= 8) || v.length >= 24;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 1.9.367 (UR-0025): 라인 머지 순수 코어 — 기존 텍스트에 없는 라인만 append (substring 중복 방지). mergeLinesFile 의 I/O 분리.
|
|
673
|
+
function _mergeLines(currentText, lines) {
|
|
674
|
+
let next = currentText || '';
|
|
675
|
+
for (const line of (lines || [])) if (!next.includes(line)) next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n';
|
|
676
|
+
return next;
|
|
677
|
+
}
|
|
678
|
+
// 1.9.367 (UR-0025): .env key-aware 머지 순수 코어 — 기존 KEY 값 보존(덮어쓰기 X), 신규 KEY/주석만 append. mergeEnvFile 의 I/O 분리.
|
|
679
|
+
function _mergeEnvLines(currentText, lines) {
|
|
680
|
+
const current = currentText || '';
|
|
681
|
+
const existingKeys = new Set();
|
|
682
|
+
for (const ln of current.split(/\r?\n/)) { const m = ln.match(/^\s*([A-Z][A-Z0-9_]+)\s*=/); if (m) existingKeys.add(m[1]); }
|
|
683
|
+
let next = current;
|
|
684
|
+
for (const line of (lines || [])) {
|
|
685
|
+
const km = line.match(/^\s*([A-Z][A-Z0-9_]+)\s*=/);
|
|
686
|
+
if (km) { if (existingKeys.has(km[1])) continue; next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n'; existingKeys.add(km[1]); }
|
|
687
|
+
else { if (!next.includes(line)) next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n'; }
|
|
688
|
+
}
|
|
689
|
+
return next;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// 1.9.368 (UR-0025): README 관리섹션 머지 순수 코어 — 마커 사이 교체, 없으면 append. 마커는 인자로 주입(harness 상수 비결합).
|
|
693
|
+
function _mergeReadmeSection(existing, block, START, END) {
|
|
694
|
+
if (!existing) return `# Project\n\n${block}`;
|
|
695
|
+
const s = existing.indexOf(START); const e = existing.indexOf(END);
|
|
696
|
+
if (s >= 0 && e >= s) return existing.slice(0, s).trimEnd() + '\n\n' + block + '\n' + existing.slice(e + END.length).trimStart();
|
|
697
|
+
return existing.trimEnd() + '\n\n' + block;
|
|
698
|
+
}
|
|
699
|
+
// 1.9.368 (UR-0025): 관리 파일 마이그레이션 머지 순수 코어 — 이전 내용을 migration-preserved 블록으로 보존(데이터/인덱스 파일은 overwrite).
|
|
700
|
+
// archiveRel(사전 계산된 표시 경로) + overwriteSet 을 인자로 주입 → path/process/상수 비결합(순수).
|
|
701
|
+
function _managedMerge(file, next, previous, archiveRel, overwriteSet) {
|
|
702
|
+
if (!previous || previous.trim() === next.trim()) return next;
|
|
703
|
+
const tag = '<!-- leerness:migration-preserved -->';
|
|
704
|
+
if (previous.includes(tag)) return next;
|
|
705
|
+
if (overwriteSet && overwriteSet.has(String(file).replace(/\\/g, '/'))) return next;
|
|
706
|
+
const ar = archiveRel || '.harness/archive';
|
|
707
|
+
return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\nPrevious content was backed up before migration. Archive reference:\n\n\`${ar}\`\n\n<details>\n<summary>Previous ${file}</summary>\n\n\`\`\`md\n${previous.replace(/```/g, '\\`\\`\\`')}\n\`\`\`\n\n</details>\n`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// 1.9.369 (UR-0025): --skills 값 파싱 순수 코어 — catalog 주입(harness skillCatalog 비결합). all/recommended/csv 처리 + catalog 필터.
|
|
711
|
+
function _parseSkillsValue(v, catalog) {
|
|
712
|
+
if (!v || v === true) return [];
|
|
713
|
+
if (v === 'all') return Object.keys(catalog || {});
|
|
714
|
+
if (v === 'recommended') return ['office', 'commerce-api', 'ai-verified-skill-publisher', 'feature-implementation', 'project-roadmap-generator'];
|
|
715
|
+
return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => (catalog || {})[s]);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 1.9.370 (UR-0025): memory archive 블록 파서 순수 코어 — "## 제거 DATE (target: \"...\")" 블록 → {date,target,originalHeader}[].
|
|
719
|
+
function _parseArchiveBlocks(text) {
|
|
720
|
+
const entries = [];
|
|
721
|
+
if (!text) return entries;
|
|
722
|
+
const blocks = text.split(/\n(?=## 제거 )/);
|
|
723
|
+
for (const b of blocks) {
|
|
724
|
+
const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
|
|
725
|
+
if (!m) continue;
|
|
726
|
+
const headerMatch = b.match(/^### (.+)$/m);
|
|
727
|
+
entries.push({ date: m[1], target: m[2], originalHeader: headerMatch ? headerMatch[1].trim() : null });
|
|
728
|
+
}
|
|
729
|
+
return entries;
|
|
730
|
+
}
|
|
731
|
+
// 1.9.370 (UR-0025): skill 카탈로그 파서 순수 코어 — JSON/RSS·Atom/markdown 링크/llms.txt 형식 → {name,url,description,format}[].
|
|
732
|
+
function _parseSkillCatalog(body, sourceUrl) {
|
|
733
|
+
const entries = [];
|
|
734
|
+
const trimmed = String(body || '').trim();
|
|
735
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
736
|
+
try {
|
|
737
|
+
const j = JSON.parse(trimmed);
|
|
738
|
+
const arr = Array.isArray(j) ? j : (j.skills || j.entries || j.items || []);
|
|
739
|
+
for (const e of arr) {
|
|
740
|
+
if (!e || (!e.name && !e.id)) continue;
|
|
741
|
+
entries.push({ name: e.name || e.id, url: e.url || e.path || (sourceUrl ? sourceUrl.replace(/[^/]+$/, '') + (e.id || e.name) + '/SKILL.md' : ''), description: e.description || '', format: 'json' });
|
|
742
|
+
}
|
|
743
|
+
if (entries.length) return entries;
|
|
744
|
+
} catch {}
|
|
745
|
+
}
|
|
746
|
+
if (/<rss|<feed|<channel|<item>/i.test(body)) {
|
|
747
|
+
for (const m of String(body).matchAll(/<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi)) {
|
|
748
|
+
const item = m[0];
|
|
749
|
+
const title = (item.match(/<title>([^<]+)<\/title>/i) || [])[1];
|
|
750
|
+
const link = (item.match(/<link[^>]*>([^<]+)<\/link>/i) || item.match(/<link\s+href="([^"]+)"/i) || [])[1];
|
|
751
|
+
const desc = (item.match(/<description>([^<]+)<\/description>/i) || item.match(/<summary>([^<]+)<\/summary>/i) || [])[1];
|
|
752
|
+
if (title) entries.push({ name: title.trim(), url: (link || '').trim(), description: (desc || '').trim(), format: 'rss' });
|
|
753
|
+
}
|
|
754
|
+
if (entries.length) return entries;
|
|
755
|
+
}
|
|
756
|
+
for (const m of String(body).matchAll(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+)\)\s*[-—:]\s*(.+)$/gm)) {
|
|
757
|
+
entries.push({ name: m[1], url: m[2], description: m[3].trim(), format: 'markdown' });
|
|
758
|
+
}
|
|
759
|
+
if (entries.length) return entries;
|
|
760
|
+
for (const m of String(body).matchAll(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+\.md)\)/gm)) {
|
|
761
|
+
entries.push({ name: m[1], url: m[2], description: '', format: 'markdown' });
|
|
762
|
+
}
|
|
763
|
+
if (entries.length) return entries;
|
|
764
|
+
for (const m of String(body).matchAll(/(https?:\/\/[^\s)]+SKILL\.md)/g)) {
|
|
765
|
+
entries.push({ name: m[1].split('/').slice(-2)[0], url: m[1], description: '', format: 'urls' });
|
|
766
|
+
}
|
|
767
|
+
return entries;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// 1.9.371 (UR-0073 Phase A): agent team 정의 → teams.md projection (canonical JSON 주, MD 투영). 순수 렌더러.
|
|
771
|
+
function _renderTeamsMd(teams) {
|
|
772
|
+
const preamble = '# Agent Teams (UR-0073)\n\n페르소나 기반 에이전트 팀 정의 — **opt-in · 정의 전용(자동 실행 없음)**. `leerness team add|list|show|remove` 로 관리.\n'
|
|
773
|
+
+ '향후 단계에서 스케줄 기반 실행(리뷰/배포/블로그)이 opt-in 으로 추가될 수 있습니다. 현재는 메타데이터만 저장합니다.\n';
|
|
774
|
+
const body = (teams || []).map(t => {
|
|
775
|
+
return `\n## ${t.id}${t.name ? ' — ' + t.name : ''}\n`
|
|
776
|
+
+ `- Purpose: ${t.purpose || ''}\n`
|
|
777
|
+
+ `- Personas: ${(t.personas || []).join(', ')}\n`
|
|
778
|
+
+ `- Members: ${(t.members || []).join(', ')}\n`
|
|
779
|
+
+ `- Schedule: ${t.schedule || 'manual'}\n`
|
|
780
|
+
+ `- Deploy: ${t.deployCommand || '-'}\n`
|
|
781
|
+
+ `- Review: ${t.review !== false ? '메인 검수 필요' : '생략'}\n`
|
|
782
|
+
+ `- Status: ${t.status || 'active'}\n`;
|
|
783
|
+
}).join('');
|
|
784
|
+
return preamble + body;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 1.9.372 (UR-0073 Phase B): team 실행 계획 컴포저 (순수, dry-run 미리보기). 실제 실행/spawn 없음 — 멤버별 dispatch 명령 문자열만 생성.
|
|
788
|
+
function _composeTeamPlan(team, task) {
|
|
789
|
+
const t = team || {};
|
|
790
|
+
const effTask = (task && task !== true) ? String(task) : (t.purpose || '(작업 미지정)');
|
|
791
|
+
const personas = Array.isArray(t.personas) ? t.personas : [];
|
|
792
|
+
const members = Array.isArray(t.members) ? t.members : [];
|
|
793
|
+
const personaTag = personas.length ? ` [페르소나: ${personas.join(', ')}]` : '';
|
|
794
|
+
const steps = members.map(m => {
|
|
795
|
+
const prompt = `${effTask}${personaTag}`;
|
|
796
|
+
return { member: m, personas, dispatchPrompt: prompt, suggestedCommand: `leerness agents dispatch "${prompt}" --to ${m}` };
|
|
797
|
+
});
|
|
798
|
+
// 1.9.414 (UR-0119/0120): 메인 에이전트 검수 단계 — sub-agent 분배 후 메인이 산출물을 교차검증(기본 on, team.review===false 시 생략).
|
|
799
|
+
const review = t.review !== false;
|
|
800
|
+
const reviewStep = review ? {
|
|
801
|
+
type: 'review',
|
|
802
|
+
note: '메인 에이전트가 각 sub-agent 산출물을 독립 검증(교차 검수). verify-claim/contract verify/review 사용.',
|
|
803
|
+
suggestedCommand: 'leerness verify-claim <T-ID> --run-tests --strict-claims · leerness review <file> --persona ' + (personas.join(',') || 'security'),
|
|
804
|
+
} : null;
|
|
805
|
+
return { teamId: t.id || null, name: t.name || '', task: effTask, schedule: t.schedule || 'manual', memberCount: members.length, review, steps, reviewStep };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 1.9.373 (UR-0073 Phase C): 비-manual·active 팀의 handoff 스케줄 알림 라인 (순수). 실행 트리거 아님 — 미리보기 안내만.
|
|
809
|
+
function _teamHandoffReminders(teams) {
|
|
810
|
+
return (teams || [])
|
|
811
|
+
.filter(t => t && t.schedule && t.schedule !== 'manual' && (t.status || 'active') === 'active' && t.id)
|
|
812
|
+
.map(t => `🤝 ${t.id} (${t.schedule})${Array.isArray(t.members) && t.members.length ? ' · ' + t.members.length + '명' : ''}${t.review !== false ? ' · 검수필요' : ''} — 미리보기: leerness team preview ${t.id}`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 1.9.374 (UR-0074): 릴리스 케이던스 평가 (순수) — releases/day → 수준 + 권장. 외부리뷰 "릴리스 빈도 과다" 가시화.
|
|
816
|
+
function _cadenceAssessment(perDay, total, daysActive) {
|
|
817
|
+
const r = Number(perDay) || 0;
|
|
818
|
+
let level, recommendation;
|
|
819
|
+
if (r >= 5) { level = 'very-high'; recommendation = 'batched minor 릴리스 강력 권장 — 관련 패치를 묶어 주 1~2회 minor 로. stable/next 채널 분리 + 사용자에겐 stable 만 권고.'; }
|
|
820
|
+
else if (r >= 2) { level = 'high'; recommendation = 'cadence 높음 — 연관 변경을 묶어 배포 빈도 축소 권장. 릴리스 노트에 실행 환경/검증 명시.'; }
|
|
821
|
+
else if (r >= 0.5) { level = 'moderate'; recommendation = '적정 범위 — 안정성 우선 시 minor 묶음 고려.'; }
|
|
822
|
+
else { level = 'healthy'; recommendation = '건강한 케이던스.'; }
|
|
823
|
+
return { releasesPerDay: r, total: Number(total) || 0, daysActive: Number(daysActive) || 0, level, recommendation };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 1.9.376 (UR-0073 Phase D): team 배포 실행 게이트 결정 (순수). 안전: dry-run 기본, 실행은 --yes + env 이중 게이트.
|
|
827
|
+
// mode: no-command(설정 없음) / dry-run(실행 안 함) / gated(env 미충족 거부) / execute(실행 허용).
|
|
828
|
+
function _teamDeployGate(team, opts) {
|
|
829
|
+
const t = team || {}; opts = opts || {};
|
|
830
|
+
const command = (t.deployCommand && t.deployCommand !== true) ? String(t.deployCommand) : '';
|
|
831
|
+
if (!command) return { mode: 'no-command', command: '', message: 'deployCommand 미설정 — team add --deploy "<명령>" 으로 지정' };
|
|
832
|
+
if (!opts.yes) return { mode: 'dry-run', command, message: 'dry-run (실행 없음) — 실행하려면 --yes + LEERNESS_TEAM_DEPLOY=1' };
|
|
833
|
+
if (!opts.envOn) return { mode: 'gated', command, message: '실행 게이트 미충족 — LEERNESS_TEAM_DEPLOY=1 환경변수 필요 (의도적 opt-in)' };
|
|
834
|
+
return { mode: 'execute', command, message: '실행 허용 (--yes + env 게이트 충족)' };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 1.9.377 (UR-0025): 워크스페이스 레퍼런스 가이드 빌더 (순수) — dirName/version/generatedAt 주입. harness 인라인(~57줄) 분리.
|
|
838
|
+
function _renderWorkspaceReferenceGuide(dirName, version, generatedAt) {
|
|
839
|
+
const lines = [];
|
|
840
|
+
lines.push(`# Leerness Workspace Reference Guide`);
|
|
841
|
+
lines.push('');
|
|
842
|
+
lines.push(`> AI 에이전트가 leerness 워크스페이스에서 어떤 파일을 어디서 찾는지 안내합니다 (1.9.211).`);
|
|
843
|
+
lines.push('');
|
|
844
|
+
lines.push(`Generated: ${generatedAt} by leerness ${version}`);
|
|
845
|
+
lines.push(`Workspace dir: \`${dirName}/\``);
|
|
846
|
+
lines.push('');
|
|
847
|
+
lines.push(`## 📁 디렉토리 구조 (핵심)`);
|
|
848
|
+
lines.push('');
|
|
849
|
+
lines.push('```');
|
|
850
|
+
lines.push(`${dirName}/`);
|
|
851
|
+
lines.push(`├── plan.md ← 무엇을 할 것인가 (사용자 메모리)`);
|
|
852
|
+
lines.push(`├── progress-tracker.md ← 무엇을 했는가 (증거 포함, 사용자 메모리)`);
|
|
853
|
+
lines.push(`├── decisions.md ← 왜 그렇게 했는가 (사용자 메모리)`);
|
|
854
|
+
lines.push(`├── session-handoff.md ← 다음 세션 인계 (사용자 메모리)`);
|
|
855
|
+
lines.push(`├── lessons.md ← 과거 교훈 (자동 fuzzy 회수)`);
|
|
856
|
+
lines.push(`├── rules.md ← 자연어 룰 (매 세션 자동 노출, R-XXXX)`);
|
|
857
|
+
lines.push(`├── task-log.md ← in-progress / dropped task 이력`);
|
|
858
|
+
lines.push(`├── reuse-map.md ← 워크스페이스 capability 매핑`);
|
|
859
|
+
lines.push(`├── skill-suggestions.md ← skill rolling history`);
|
|
860
|
+
lines.push(`├── feature-graph.md ← 기능 의존 그래프 (F-XXXX)`);
|
|
861
|
+
lines.push(`├── manifest.json ← 워크스페이스 메타`);
|
|
862
|
+
lines.push(`├── leerness-config.json ← 비시크릿 LEERNESS_* 설정 (1.9.187, AI 가시)`);
|
|
863
|
+
lines.push(`├── user-requests.json ← 사용자 명시 요청 누적 (1.9.207)`);
|
|
864
|
+
lines.push(`├── active-wakeups.json ← ScheduleWakeup 상태 (1.9.205)`);
|
|
865
|
+
lines.push(`├── pre-wake-report.json ← sleep 전 sub-agent audit (1.9.209)`);
|
|
866
|
+
lines.push(`├── wakeup-history.json ← adaptive wakeup 이력 (1.9.210)`);
|
|
867
|
+
lines.push(`├── platform-constraints.json ← API 제약 catalog (1.9.208)`);
|
|
868
|
+
lines.push(`├── auto-resume-plan.json ← 다음 라운드 plan (1.9.203)`);
|
|
869
|
+
lines.push(`├── next-action-queue.json ← 다음 next-action 큐 (1.9.201)`);
|
|
870
|
+
lines.push(`├── last-handoff.json ← 마지막 handoff timestamp`);
|
|
871
|
+
lines.push(`├── environment.json ← 환경 변동 추적 (1.9.145)`);
|
|
872
|
+
lines.push(`├── skills/ ← 설치된 skill 디렉토리`);
|
|
873
|
+
lines.push(`└── templates/ ← 워크스페이스 템플릿`);
|
|
874
|
+
lines.push('```');
|
|
875
|
+
lines.push('');
|
|
876
|
+
lines.push(`## 🧭 자주 묻는 위치`);
|
|
877
|
+
lines.push('');
|
|
878
|
+
lines.push(`| 찾는 것 | 위치 |`);
|
|
879
|
+
lines.push(`|---|---|`);
|
|
880
|
+
lines.push(`| 현재 진행 중인 task | \`${dirName}/progress-tracker.md\` (status: in-progress) |`);
|
|
881
|
+
lines.push(`| 사용자가 명시한 영구 룰 | \`${dirName}/rules.md\` (active R-XXXX) |`);
|
|
882
|
+
lines.push(`| 직전 sleep 전 audit 결과 | \`${dirName}/pre-wake-report.json\` (1.9.209) |`);
|
|
883
|
+
lines.push(`| 미답 사용자 요청 | \`${dirName}/user-requests.json\` (status: open) |`);
|
|
884
|
+
lines.push(`| 다음 라운드 권장 단계 | \`${dirName}/auto-resume-plan.json\` (1.9.203) |`);
|
|
885
|
+
lines.push(`| API 제약 catalog | \`${dirName}/platform-constraints.json\` (1.9.208) |`);
|
|
886
|
+
lines.push(`| 자동 wakeup 권장 간격 | \`${dirName}/wakeup-history.json\` (1.9.210) |`);
|
|
887
|
+
lines.push('');
|
|
888
|
+
lines.push(`## 🔄 마이그레이션 안내`);
|
|
889
|
+
lines.push('');
|
|
890
|
+
lines.push(`이 워크스페이스는 \`.harness\` → \`.leerness\` 로 마이그레이션되었을 수 있습니다.`);
|
|
891
|
+
lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 존재 → 마이그레이션 완료, \`.leerness\` 우선 사용`);
|
|
892
|
+
lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 존재 → \`.leerness/\` 로 가야 함`);
|
|
893
|
+
lines.push(`- 양쪽 모두 없음 → 기본 \`.harness\` 사용 중`);
|
|
894
|
+
lines.push('');
|
|
895
|
+
lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 자동으로 올바른 디렉토리를 사용합니다.`);
|
|
896
|
+
lines.push('');
|
|
897
|
+
return lines.join('\n');
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 1.9.379 (UR-0025 심화): Memory Surface 포맷 (순수) — T/D/R/P/L 카운트 → 문자열. pulse/memory-status 단일출처.
|
|
901
|
+
function _memorySurface(counts) {
|
|
902
|
+
const c = counts || {};
|
|
903
|
+
return `T${c.tasks || 0}/D${c.decisions || 0}/R${c.rules || 0}/P${c.milestones || 0}/L${c.lessons || 0}`;
|
|
904
|
+
}
|
|
905
|
+
// 1.9.379 (UR-0025 심화): pulse 한 줄 요약 조합 (순수) — gather(I/O)된 data → 한 줄 문자열. pulse 핸들러 렌더 코어.
|
|
906
|
+
function _renderPulseLine(data) {
|
|
907
|
+
const d = data || {};
|
|
908
|
+
let line = `📍 v${d.version} · 🔄 R${d.roundCount} · 🔌 MCP ${d.mcpTools} · 🧠 ${d.memorySurface}`;
|
|
909
|
+
if (d.nextMilestone) {
|
|
910
|
+
const eta = d.etaDays != null ? ` (${d.etaDays}d)` : '';
|
|
911
|
+
line += ` · 🎯 R${d.nextMilestone}${eta}`;
|
|
912
|
+
}
|
|
913
|
+
if (d.abnormalShutdown && d.abnormalShutdown !== 'none') {
|
|
914
|
+
line += ` · 🔌 abnormal:${d.abnormalShutdown}`;
|
|
915
|
+
}
|
|
916
|
+
return line;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 1.9.429 (10th 외부평가 UR-0129): impl 소스에서 export 식별자 추출.
|
|
920
|
+
// 브레이스 균형으로 module.exports={...} 의 top-level 키만(함수 본문/중첩객체 안전 — 멀티라인 첫키만 버그 수정)
|
|
921
|
+
// + exports.foo + ESM(export function/const/let/var/class, export {a, b as c}) 인식.
|
|
922
|
+
function _parseImplExports(src) {
|
|
923
|
+
const out = new Set();
|
|
924
|
+
const add = n => { if (n && /^[A-Za-z_$][\w$]*$/.test(n)) out.add(n); };
|
|
925
|
+
// 1) module.exports = { ... } — 브레이스 균형 + top-level 키
|
|
926
|
+
const re = /module\.exports\s*=\s*\{/g; let mm;
|
|
927
|
+
while ((mm = re.exec(src))) {
|
|
928
|
+
const i = src.indexOf('{', mm.index); let depth = 0, end = -1;
|
|
929
|
+
for (let j = i; j < src.length; j++) { const c = src[j]; if (c === '{') depth++; else if (c === '}') { if (--depth === 0) { end = j; break; } } }
|
|
930
|
+
if (end < 0) break;
|
|
931
|
+
const inner = src.slice(i + 1, end);
|
|
932
|
+
let d = 0, seg = ''; const segs = [];
|
|
933
|
+
for (const c of inner) { if (c === '{' || c === '(' || c === '[') d++; else if (c === '}' || c === ')' || c === ']') d--; if (d === 0 && c === ',') { segs.push(seg); seg = ''; } else seg += c; }
|
|
934
|
+
if (seg.trim()) segs.push(seg);
|
|
935
|
+
for (const s of segs) { const m = s.match(/^\s*\.{0,3}\s*([A-Za-z_$][\w$]*)/); if (m && !/^\s*\.\.\./.test(s)) add(m[1]); }
|
|
936
|
+
re.lastIndex = end;
|
|
937
|
+
}
|
|
938
|
+
// 2) exports.foo = / module.exports.foo =
|
|
939
|
+
for (const m of src.matchAll(/(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g)) add(m[1]);
|
|
940
|
+
// 3) ESM 선언: export [async] function*/const/let/var/class foo
|
|
941
|
+
for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g)) add(m[1]);
|
|
942
|
+
// 4) ESM 목록/재export: export { foo, bar as baz } / export { default as X } from './m' → 외부이름(as 뒤) 우선.
|
|
943
|
+
// 1.9.438 (11th 외부평가 Sonnet P3, UR-0139): `default as X` 는 별칭 X 가 named export → as 별칭을 먼저 채택(이전엔 'default' 시작이라 통째로 스킵). 'export * from' 은 이름 정적불가라 미지원.
|
|
944
|
+
for (const m of src.matchAll(/export\s*\{([^}]+)\}/g)) {
|
|
945
|
+
for (const part of m[1].split(',')) {
|
|
946
|
+
const seg = part.trim(); if (!seg) continue;
|
|
947
|
+
const asM = seg.match(/\bas\s+([A-Za-z_$][\w$]*)/);
|
|
948
|
+
if (asM) { add(asM[1]); continue; } // a as b / default as b → b
|
|
949
|
+
if (/^(?:default|type)\b/.test(seg)) continue; // 단독 default / type X 제외
|
|
950
|
+
add((seg.match(/^([A-Za-z_$][\w$]*)/) || [])[1]);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return [...out];
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 1.11.4 (UR-0007): 용어집(glossary) 순수 코어 — 의존성→큐레이션 카탈로그 매칭 + MD 렌더. 무LLM·0deps. (외부 3-에이전트 평가 종합 설계)
|
|
957
|
+
function _matchTool(catalog, name) {
|
|
958
|
+
if (!catalog || !catalog.tools || !name) return null;
|
|
959
|
+
const n = String(name).toLowerCase().trim();
|
|
960
|
+
for (const [id, t] of Object.entries(catalog.tools)) {
|
|
961
|
+
if ((t.aliases || []).some(a => a.toLowerCase() === n)) {
|
|
962
|
+
return { id, category: t.category || 'other', plainKo: t.plainKo || '', plainEn: t.plainEn || '', docs: t.docs || null };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
// package.json 본문 → 의존성 이름 배열(dependencies + devDependencies + peerDependencies). 순수(텍스트 입력).
|
|
968
|
+
function _parsePackageJsonDeps(pkgJsonText) {
|
|
969
|
+
let pkg; try { pkg = JSON.parse(pkgJsonText); } catch { return []; }
|
|
970
|
+
const out = [];
|
|
971
|
+
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
972
|
+
const o = pkg && pkg[field]; if (o && typeof o === 'object') for (const k of Object.keys(o)) if (!out.includes(k)) out.push(k);
|
|
973
|
+
}
|
|
974
|
+
return out;
|
|
975
|
+
}
|
|
976
|
+
// requirements.txt 본문 → 파이썬 패키지명 배열(버전/주석 제거). 순수.
|
|
977
|
+
function _parseRequirementsTxt(text) {
|
|
978
|
+
if (!text || typeof text !== 'string') return [];
|
|
979
|
+
const out = [];
|
|
980
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
981
|
+
const line = raw.replace(/#.*$/, '').trim(); if (!line) continue;
|
|
982
|
+
const m = line.match(/^([A-Za-z0-9_.\-]+)/); if (m && !out.includes(m[1])) out.push(m[1]);
|
|
983
|
+
}
|
|
984
|
+
return out;
|
|
985
|
+
}
|
|
986
|
+
// 의존성 목록 + 카탈로그 → 용어집 엔트리(매칭 + 미매칭 gap). descFor(name)=로컬 fallback 설명(없으면 null). 순수.
|
|
987
|
+
function _buildGlossary({ deps = [], catalog, descFor = () => null } = {}) {
|
|
988
|
+
const entries = [], gaps = []; const seen = new Set();
|
|
989
|
+
for (const name of deps) {
|
|
990
|
+
if (seen.has(name)) continue; seen.add(name);
|
|
991
|
+
const hit = _matchTool(catalog, name);
|
|
992
|
+
if (hit) { entries.push({ term: name, plainKo: hit.plainKo, plainEn: hit.plainEn, category: hit.category, source: 'catalog', docs: hit.docs }); continue; }
|
|
993
|
+
const d = descFor(name);
|
|
994
|
+
if (d) entries.push({ term: name, plainKo: _lineSafe(d), plainEn: _lineSafe(d), category: 'dependency', source: 'package-description', docs: null });
|
|
995
|
+
else gaps.push({ term: name, category: 'dependency', source: 'unknown', needsDefinition: true });
|
|
996
|
+
}
|
|
997
|
+
entries.sort((a, b) => a.term.localeCompare(b.term));
|
|
998
|
+
return { entries, gaps, stats: { total: deps.length, defined: entries.length, gaps: gaps.length } };
|
|
999
|
+
}
|
|
1000
|
+
const GLOSSARY_START = '<!-- leerness:glossary:start -->';
|
|
1001
|
+
const GLOSSARY_END = '<!-- leerness:glossary:end -->';
|
|
1002
|
+
// 용어집 엔트리 → 이중언어 MD(마커 래핑, drift-aware). 순수.
|
|
1003
|
+
function _renderGlossaryMd(entries, opts = {}) {
|
|
1004
|
+
const lang = opts.lang || 'both'; const gaps = opts.gaps || [];
|
|
1005
|
+
let s = `${GLOSSARY_START}\n# 용어집 / Glossary\n\n> 이 프로젝트가 사용하는 도구/라이브러리를 비개발자도 알 수 있게 한 줄로 설명합니다. (leerness glossary)\n\n`;
|
|
1006
|
+
if (!entries.length && !gaps.length) { s += '_(의존성 없음 — package.json/requirements.txt 미발견)_\n'; return s + GLOSSARY_END + '\n'; }
|
|
1007
|
+
if (entries.length) {
|
|
1008
|
+
s += '| 패키지 | 쉽게 말하면 (KO) | In plain terms (EN) | 분류 | 출처 |\n|---|---|---|---|---|\n';
|
|
1009
|
+
for (const e of entries) {
|
|
1010
|
+
const ko = lang === 'en' ? '' : _lineSafe(e.plainKo || '');
|
|
1011
|
+
const en = lang === 'ko' ? '' : _lineSafe(e.plainEn || '');
|
|
1012
|
+
s += `| ${_lineSafe(e.term)} | ${ko} | ${en} | ${_lineSafe(e.category || '')} | ${e.source} |\n`;
|
|
1013
|
+
}
|
|
1014
|
+
s += '\n';
|
|
1015
|
+
}
|
|
1016
|
+
if (gaps.length) {
|
|
1017
|
+
s += `## 미정의 (${gaps.length}) — AI 에이전트가 채울 항목\n\n카탈로그·로컬 설명에 없는 의존성입니다. 사용 중인 AI 에이전트에게 아래를 요청하세요:\n\n`;
|
|
1018
|
+
s += '> 다음 패키지들을 비개발자도 이해할 한 줄(한국어+영어)로 설명해줘: ' + gaps.map(g => _lineSafe(g.term)).join(', ') + '\n';
|
|
1019
|
+
}
|
|
1020
|
+
return s + GLOSSARY_END + '\n';
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
module.exports = {
|
|
1024
|
+
_parseImplExports,
|
|
1025
|
+
_matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
|
|
1026
|
+
_isSecretKey, compareVer, parseHarnessVersion,
|
|
1027
|
+
_isPlaceholderSecret, _looksSecretLike,
|
|
1028
|
+
_mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue,
|
|
1029
|
+
_parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine,
|
|
1030
|
+
_classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
|
|
1031
|
+
// 1.9.283 (UR-0025 2단계)
|
|
1032
|
+
PERMISSION_TIERS, _tierRank, _requiredTier, _policyAllows, _resolveNpmTag, _mcpJsonContent, _newRunRecord,
|
|
1033
|
+
// 1.9.318 (UR-0025): 순수 HTML 파싱 유틸
|
|
1034
|
+
_htmlToText, _extractTitle, _extractLinks,
|
|
1035
|
+
// 1.9.324 (UR-0025): 순수 메모리 MD 파서
|
|
1036
|
+
_countDatedBlocks, _extractDecisionBlocks,
|
|
1037
|
+
// 1.9.325 (UR-0025): 순수 intent 분류
|
|
1038
|
+
_classifyIntent,
|
|
1039
|
+
// 1.9.326 (UR-0025): 순수 문자열/셸/env 유틸
|
|
1040
|
+
_sanitizeFences, _shellQuoteArg, _detectPwshFromEnv,
|
|
1041
|
+
// 1.9.327 (UR-0025): 순수 TZ/날짜 포맷
|
|
1042
|
+
_getLocalTz, _formatLocal,
|
|
1043
|
+
// 1.9.328 (UR-0025): 순수 문자열 유틸
|
|
1044
|
+
_truncate, _splitList,
|
|
1045
|
+
// 1.9.329 (UR-0025): 순수 roadmap MD 파서
|
|
1046
|
+
_roadmapMapStatus, _roadmapParseMilestones, _roadmapParseTokens,
|
|
1047
|
+
// 1.9.330 (UR-0025): project-brief 필드 config + 채움 카운트
|
|
1048
|
+
_BRIEF_FIELDS, _briefFilled,
|
|
1049
|
+
// 1.9.331 (UR-0025): project-brief 텍스트 빌더 + 마커
|
|
1050
|
+
BRIEF_START, BRIEF_END, _briefReadmeBlock, _briefBlueprint,
|
|
1051
|
+
// 1.9.332/UR-0058: 순수 lessons.md 파서 + canonical projection renderer
|
|
1052
|
+
_parseLessonEntries, _renderLessonsMd,
|
|
1053
|
+
// 1.9.341 (UR-0025 심층): 내장 스킬 catalog _source 부여
|
|
1054
|
+
_withBuiltinSource,
|
|
1055
|
+
// 1.9.345 (UR-0025 심층): HTML escape (출력 인젝션 방지)
|
|
1056
|
+
_esc,
|
|
1057
|
+
// 1.9.346 (UR-0025 심층): roadmap CSS 변수 빌더
|
|
1058
|
+
_roadmapTokenStyles,
|
|
1059
|
+
// 1.9.347 (UR-0025 심층): SKILL.md frontmatter 파서 (BOM-aware)
|
|
1060
|
+
_parseSkillMd,
|
|
1061
|
+
// 1.9.333 (UR-0025 심층): 순수 플랫폼 제약 매칭
|
|
1062
|
+
_matchConstraints,
|
|
1063
|
+
// 1.9.333 패턴 적용: 순수 도메인 매칭
|
|
1064
|
+
_matchDomain,
|
|
1065
|
+
// 1.9.335 (UR-0025 심층): LSP 서브시스템 — 순수 언어감지 + 정규식 심볼 매처
|
|
1066
|
+
_detectLspLang, _matchLspSymbols,
|
|
1067
|
+
// anti-laziness optimism-check 순수 로직
|
|
1068
|
+
_extractUrlClaims, _verifyUrlClaim, _detectOptimism, _computeConfidence,
|
|
1069
|
+
// 1.9.337 (UR-0025 심층): persona 요약 목록
|
|
1070
|
+
_personaSummaries,
|
|
1071
|
+
// 1.9.338 (UR-0025 심층): i18n 순수 조회
|
|
1072
|
+
_translate,
|
|
1073
|
+
// 1.9.339 (UR-0053): decisions canonical 파서/렌더 (JSON canonical, MD projection)
|
|
1074
|
+
_parseDecisionBlock, _decisionsFromMd, _renderDecisionsMd,
|
|
1075
|
+
// 1.9.355 (UR-0075 Phase A): 크로스버전 마이그레이션 가이드
|
|
1076
|
+
_migrationGuideText,
|
|
1077
|
+
// 1.9.385 (UR-0086, 5th외부평가): contract spec 순수 파서 (markdown bullet 함수 선언 감지)
|
|
1078
|
+
_parseContractSpec,
|
|
1079
|
+
// 1.9.386 (UR-0087, 5th외부평가): 간이 .gitignore 매칭 + glob (bare .env → .env.* 과잉보호 제거)
|
|
1080
|
+
_gitignoreMatch, _globToRe,
|
|
1081
|
+
// 1.9.390 (UR-0025): feature-graph 순수 코어 (템플릿/파서/ID/블록)
|
|
1082
|
+
_featureGraphTemplate, _parseFeatureGraph, _nextFeatureId, _featureBlock,
|
|
1083
|
+
// 1.9.391 (UR-0025): feature 영향 BFS (순수, 공유)
|
|
1084
|
+
_featureImpactBfs,
|
|
1085
|
+
// 1.9.393 (UR-0025): CHANGELOG 버전 구간 차분 파서 (순수, 공유)
|
|
1086
|
+
_parseChangelogBetween,
|
|
1087
|
+
// 1.9.399 (7번째 버그헌트 P1-A, UR-0104): markdown 테이블 셀 안전화(파이프/개행 injection 차단)
|
|
1088
|
+
_cellSafe, _cellUnescape,
|
|
1089
|
+
// 1.9.402 (7번째 버그헌트 P1-A 잔여, UR-0108): MD projection 라인 안전화(개행→공백)
|
|
1090
|
+
_lineSafe,
|
|
1091
|
+
// 1.9.407 (8번째 버그헌트, UR-0111): --limit 안전 파싱(NaN/음수/0 → 기본값)
|
|
1092
|
+
_parseLimit,
|
|
1093
|
+
// 1.9.416 (9th 외부평가, UR-0122): add 류 제목 파싱(flag/경로 break) 단일 출처
|
|
1094
|
+
_parseAddTitle,
|
|
1095
|
+
// 1.9.442 (12th 외부평가, UR-0141): task 계열 positional path 안전 추출
|
|
1096
|
+
_taskPositionalPath,
|
|
1097
|
+
// 1.9.443 (GPT-5.5 전략리뷰 §6.3, UR-0153): evidence-first 완료 게이트
|
|
1098
|
+
_completionClaimAllowed,
|
|
1099
|
+
// 1.9.446 (R-0011/UR-0160): npm 배포 minor-gate
|
|
1100
|
+
_minorKey, _shouldPublishNpm
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
// 1.9.355 (UR-0075 Phase A): AI 에이전트용 크로스버전 마이그레이션 안전 워크플로 가이드 (순수 텍스트). 임시설치 + --path + 백업 + diff 검증.
|
|
1104
|
+
function _migrationGuideText(version) {
|
|
1105
|
+
const v = version || 'latest';
|
|
1106
|
+
const L = [
|
|
1107
|
+
'# leerness 크로스버전 마이그레이션 가이드 (UR-0075, AI 에이전트용)',
|
|
1108
|
+
'',
|
|
1109
|
+
'아주 오래된 구버전부터 신규(' + v + ')까지 — 기존 프로젝트의 .harness 내용을 안전·비파괴로 마이그레이션.',
|
|
1110
|
+
'',
|
|
1111
|
+
'## 0. 원칙',
|
|
1112
|
+
'- 비파괴: leerness 는 migrate/update 시 .harness/archive 에 자동 백업. 그래도 git 커밋/브랜치 선행 권장.',
|
|
1113
|
+
'- dry-run 우선: 먼저 --check 로 감지, diff 로 확인 후 적용.',
|
|
1114
|
+
'',
|
|
1115
|
+
'## 1. 안전 스냅샷 (권장)',
|
|
1116
|
+
' git add -A && git commit -m "chore: pre-leerness-migration snapshot" # 또는 브랜치: git checkout -b chore/leerness-migrate',
|
|
1117
|
+
'',
|
|
1118
|
+
'## 2. 신규 버전 감지 (구버전 프로젝트 대상)',
|
|
1119
|
+
' npx leerness@latest update --check --path <project> # 현재 버전 vs 최신 비교 (네트워크 비차단, 비파괴)',
|
|
1120
|
+
'',
|
|
1121
|
+
'## 3. 마이그레이션 적용 (임시설치 = npx 캐시, 격리)',
|
|
1122
|
+
' npx leerness@latest update --yes --path <project> # 자동 마이그레이션 (.harness/archive 백업 + 신 스키마 반영)',
|
|
1123
|
+
' # 또는: npx leerness@latest migrate <project> --force # 강제 재스캐폴딩(비파괴, 기존 내용 보존)',
|
|
1124
|
+
'',
|
|
1125
|
+
'## 4. 검증 (필수)',
|
|
1126
|
+
' git -C <project> diff # 생성/수정 파일 전수 확인 (예상치 못한 변경 점검)',
|
|
1127
|
+
' npx leerness@latest selftest # 코어 무결성 (위치독립, 어디서든 통과)',
|
|
1128
|
+
' npx leerness@latest check --path <project> # 프로젝트 무결성',
|
|
1129
|
+
' npx leerness@latest doctor # 설치 진단',
|
|
1130
|
+
'',
|
|
1131
|
+
'## 5. 크로스버전 메모',
|
|
1132
|
+
'- decisions/lessons: 구 MD-only → canonical JSON 자동 백필(첫 write 시). decisions.json/lessons.json 이 진실소스, .md 는 projection.',
|
|
1133
|
+
'- 아주 구버전: update 가 단계적으로 누적 마이그레이션. 한 번에 안 되면 update --yes 재실행.',
|
|
1134
|
+
'- 보호 파일(.harness/protected-files.md): 삭제 금지 — merge/archive/deprecated 마커 사용.',
|
|
1135
|
+
'',
|
|
1136
|
+
'## 6. 롤백',
|
|
1137
|
+
' git -C <project> checkout -- . # git 스냅샷 복원',
|
|
1138
|
+
' # 또는 .harness/archive/<timestamp> 에서 수동 복구 · leerness memory restore <surface> <target>',
|
|
1139
|
+
''
|
|
1140
|
+
];
|
|
1141
|
+
return L.join('\n');
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// 1.9.385 (UR-0086, 5번째 외부평가): contract spec 함수/필드 추출 — 순수 파서.
|
|
1145
|
+
// declared(강선언, 검사 대상): "function name(" + markdown bullet "- name(args)" / "* " / "1. ".
|
|
1146
|
+
// mentioned(약언급, 표시만): backtick `name(` — 산문 인라인 언급일 수 있어 누락검사 제외(기존 관대성 유지).
|
|
1147
|
+
// fields: tick.name. bullet 패턴은 name 직후 '(' (공백 불허) → 산문 "- 합 (a+b)" 오탐 방지, ASCII 식별자만.
|
|
1148
|
+
function _parseContractSpec(specText) {
|
|
1149
|
+
const s = specText || '';
|
|
1150
|
+
const declared = new Set();
|
|
1151
|
+
const mentioned = new Set();
|
|
1152
|
+
const fields = new Set();
|
|
1153
|
+
for (const m of s.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) declared.add(m[1]);
|
|
1154
|
+
// 1.9.433 (11th 외부평가 Opus P1): bullet 시작 backtick 허용 — `- ` + ` `name()` `(CLI 자체 관례)도 강선언. 인라인 산문 backtick(아래)은 약언급 유지.
|
|
1155
|
+
for (const m of s.matchAll(/^\s*(?:[-*+]|\d+\.)\s+`?([A-Za-z_$][\w$]*)\(/gm)) declared.add(m[1]);
|
|
1156
|
+
for (const m of s.matchAll(/`([A-Za-z_$][\w$]*)\s*\(/g)) mentioned.add(m[1]);
|
|
1157
|
+
for (const m of s.matchAll(/tick\.([A-Za-z_$][\w$]*)/g)) fields.add(m[1]);
|
|
1158
|
+
// 1.9.417 (9th 외부평가 Opus, UR-0123): `## Fields`(또는 `## 필드`) 섹션 불릿도 필드로 인식.
|
|
1159
|
+
// 기존엔 tick. 프리픽스 전용이라 범용 spec 의 필드 계약이 무력화(원래 TICK_SPEC 예제 잔재). 섹션 한정 파싱이라 산문 오탐 없음.
|
|
1160
|
+
// 불릿 식별자 추출: "- userId" / "* userId: string" / "- userId (설명)" → userId. 식별자 직후 ( 면 함수라 제외(:|공백|줄끝만 허용).
|
|
1161
|
+
{
|
|
1162
|
+
const lines = s.split(/\r\n?|\n/);
|
|
1163
|
+
let inFields = false;
|
|
1164
|
+
for (const line of lines) {
|
|
1165
|
+
const h = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
1166
|
+
if (h) { const t = h[1].trim().toLowerCase(); inFields = t === 'fields' || t.startsWith('fields ') || h[1].trim().startsWith('필드'); continue; }
|
|
1167
|
+
if (!inFields) continue;
|
|
1168
|
+
// 1.9.433 (11th 외부평가 Codex P2): bullet 시작 backtick 허용 — `- ` + ` `name`: desc `(설명 붙은 필드 관용 표기)도 필드로 인식.
|
|
1169
|
+
const b = line.match(/^\s*(?:[-*+]|\d+\.)\s+`?([A-Za-z_$][\w$]*)`?\s*(?::|\s|$)/);
|
|
1170
|
+
if (b) fields.add(b[1]);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return { declared: [...declared], mentioned: [...mentioned], fields: [...fields] };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// 1.9.386 (UR-0087, 5번째 외부평가): 간이 glob → 정규식. '*' → [^/]* (경로구분 제외), 나머지는 리터럴.
|
|
1177
|
+
function _globToRe(glob) {
|
|
1178
|
+
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
|
|
1179
|
+
return new RegExp('^' + esc + '$');
|
|
1180
|
+
}
|
|
1181
|
+
// 1.9.386 (UR-0087, 5번째 외부평가): 간이 .gitignore 매칭 (순수). full semantics 아님 — 정확매칭 / glob(*) / dir(/) 지원.
|
|
1182
|
+
// 1.9.365 의 과잉 휴리스틱(bare '.env' → 모든 '.env.*' 보호) 제거 → git 실제 동작과 일치:
|
|
1183
|
+
// '.env' 는 '.env' 만 매칭(.env.bad 미보호 = 커밋 대상). '.env.*' / '.env*' 같은 명시 glob 만 .env.bad 보호.
|
|
1184
|
+
function _gitignoreMatch(giText, fileRel) {
|
|
1185
|
+
if (!giText) return false;
|
|
1186
|
+
const relPosix = String(fileRel).replace(/\\/g, '/');
|
|
1187
|
+
const base = relPosix.split('/').pop();
|
|
1188
|
+
// 1.9.401 (7번째 버그헌트 P1-C, UR-0106): 부정(!) 패턴 + last-match-wins(git 실제 동작).
|
|
1189
|
+
// 종전: '!' 라인 무시 → '*.example' + '!.env.example' 시 .env.example(커밋대상)을 gitignored 로 오판 → 시크릿 FN.
|
|
1190
|
+
// 수정: 매칭마다 ignored 갱신, '!' 매칭은 un-ignore, 마지막 매칭이 최종.
|
|
1191
|
+
let ignored = false;
|
|
1192
|
+
for (let pat of String(giText).split(/\r?\n/)) {
|
|
1193
|
+
pat = pat.trim();
|
|
1194
|
+
if (!pat || pat.startsWith('#')) continue;
|
|
1195
|
+
let negate = false;
|
|
1196
|
+
if (pat.startsWith('!')) { negate = true; pat = pat.slice(1); }
|
|
1197
|
+
const isDir = pat.endsWith('/');
|
|
1198
|
+
const p = pat.replace(/^\/+|\/+$/g, '');
|
|
1199
|
+
if (!p) continue;
|
|
1200
|
+
let m = false;
|
|
1201
|
+
if (p === relPosix || p === base) m = true; // 정확 매칭 (.env → .env)
|
|
1202
|
+
else if (isDir && (relPosix === p || relPosix.startsWith(p + '/'))) m = true; // dir/
|
|
1203
|
+
else if (p.includes('*')) { const re = _globToRe(p); if (re.test(p.includes('/') ? relPosix : base)) m = true; } // glob
|
|
1204
|
+
if (m) ignored = !negate; // last-match-wins; '!' 는 un-ignore
|
|
1205
|
+
}
|
|
1206
|
+
return ignored;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// 1.9.390 (UR-0025): feature-graph 순수 코어 — 템플릿/파서/ID/블록 렌더 (I/O 없음). harness 의 _readFeatureGraph/_writeFeatureGraph 가 사용.
|
|
1210
|
+
function _featureGraphTemplate() {
|
|
1211
|
+
return `# Feature Graph (1.9.141)\n\n` +
|
|
1212
|
+
`> **목적**: 각 기능의 인과관계를 정확히 정리해서 코드 작성 전 영향 범위를 자동 추적.\n` +
|
|
1213
|
+
`> 신규 기능 추가, 데이터 형식 변경, 외부 API 매칭 작업 전 \`leerness feature impact <id>\`로 확인.\n` +
|
|
1214
|
+
`> handoff가 현재 task 키워드로 자동 매칭해서 영향받는 feature 목록을 회수.\n\n` +
|
|
1215
|
+
`## How to use\n\n` +
|
|
1216
|
+
`\`\`\`bash\n` +
|
|
1217
|
+
`leerness feature add "User Auth" # F-0001 자동 부여\n` +
|
|
1218
|
+
`leerness feature link F-0002 --depends-on F-0001 # 의존 관계\n` +
|
|
1219
|
+
`leerness feature link F-0001 --affects F-0002,F-0005 # 영향 관계 (다수)\n` +
|
|
1220
|
+
`leerness feature link F-0001 --co-changes-with F-0011 # 함께 변해야 하는 기능\n` +
|
|
1221
|
+
`leerness feature impact F-0001 # 영향받는 전체 (transitive)\n` +
|
|
1222
|
+
`leerness feature list --json # 그래프 JSON\n` +
|
|
1223
|
+
`leerness feature show F-0001 # 단일 상세\n` +
|
|
1224
|
+
`\`\`\`\n\n` +
|
|
1225
|
+
`## Nodes\n\n`;
|
|
1226
|
+
}
|
|
1227
|
+
function _parseFeatureGraph(text) {
|
|
1228
|
+
if (!text) return [];
|
|
1229
|
+
const nodes = [];
|
|
1230
|
+
const re = /^## (F-\d{4})\s+(.+?)\s*$/gm;
|
|
1231
|
+
const positions = [];
|
|
1232
|
+
let m;
|
|
1233
|
+
while ((m = re.exec(text)) !== null) positions.push({ id: m[1], title: m[2], start: m.index });
|
|
1234
|
+
for (let i = 0; i < positions.length; i++) {
|
|
1235
|
+
const start = positions[i].start;
|
|
1236
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : text.length;
|
|
1237
|
+
const block = text.slice(start, end);
|
|
1238
|
+
const parseField = (key) => {
|
|
1239
|
+
// 1.9.141 fix: \s 은 \n 도 포함하므로 [ \t]* 로 newline 비포함 horizontal whitespace 만 매칭
|
|
1240
|
+
const r = new RegExp(`^- ${key}:[ \\t]*(.*?)$`, 'mi');
|
|
1241
|
+
const mm = block.match(r);
|
|
1242
|
+
return mm ? mm[1].trim() : '';
|
|
1243
|
+
};
|
|
1244
|
+
const parseList = (key) => {
|
|
1245
|
+
const v = parseField(key);
|
|
1246
|
+
if (!v) return [];
|
|
1247
|
+
return v.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
|
1248
|
+
};
|
|
1249
|
+
nodes.push({
|
|
1250
|
+
id: positions[i].id,
|
|
1251
|
+
title: positions[i].title,
|
|
1252
|
+
dependsOn: parseList('depends-on'),
|
|
1253
|
+
affects: parseList('affects'),
|
|
1254
|
+
coChangesWith: parseList('co-changes-with'),
|
|
1255
|
+
files: parseList('files'),
|
|
1256
|
+
input: parseField('input'),
|
|
1257
|
+
output: parseField('output'),
|
|
1258
|
+
errorModes: parseList('error-modes'),
|
|
1259
|
+
tests: parseList('tests'),
|
|
1260
|
+
notes: parseField('notes')
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
return nodes;
|
|
1264
|
+
}
|
|
1265
|
+
function _nextFeatureId(nodes) {
|
|
1266
|
+
const used = new Set(nodes.map(n => parseInt(n.id.slice(2), 10)));
|
|
1267
|
+
let n = 1; while (used.has(n)) n++;
|
|
1268
|
+
return 'F-' + String(n).padStart(4, '0');
|
|
1269
|
+
}
|
|
1270
|
+
function _featureBlock(node) {
|
|
1271
|
+
// 1.11.1 (14th 버그헌트 P1, UR-0177): 모든 보간 값 _lineSafe(개행→공백) — 기존엔 title/input/output/notes 를 raw 기록해 'X\n## F-9999 …' 로 가짜 노드(헤더) 위조 가능했음. decisions/lessons(_lineSafe)와 동일 정책.
|
|
1272
|
+
const arr = (a) => (a || []).map(_lineSafe).join(', ');
|
|
1273
|
+
return `## ${node.id} ${_lineSafe(node.title || '')}\n` +
|
|
1274
|
+
`- depends-on: ${arr(node.dependsOn)}\n` +
|
|
1275
|
+
`- affects: ${arr(node.affects)}\n` +
|
|
1276
|
+
`- co-changes-with: ${arr(node.coChangesWith)}\n` +
|
|
1277
|
+
`- files: ${arr(node.files)}\n` +
|
|
1278
|
+
`- input: ${_lineSafe(node.input || '')}\n` +
|
|
1279
|
+
`- output: ${_lineSafe(node.output || '')}\n` +
|
|
1280
|
+
`- error-modes: ${arr(node.errorModes)}\n` +
|
|
1281
|
+
`- tests: ${arr(node.tests)}\n` +
|
|
1282
|
+
`- notes: ${_lineSafe(node.notes || '')}\n\n`;
|
|
1283
|
+
}
|
|
1284
|
+
// 1.9.391 (UR-0025): feature 영향 BFS — affects + co-changes-with transitive + depends-on 역방향. 순수(nodes,startId→result). harness(handoff/audit)+lib/feature 공유.
|
|
1285
|
+
function _featureImpactBfs(nodes, startId) {
|
|
1286
|
+
const byId = new Map(nodes.map(n => [n.id, n]));
|
|
1287
|
+
const visited = new Set();
|
|
1288
|
+
const queue = [{ id: startId, depth: 0, via: 'self' }];
|
|
1289
|
+
const result = [];
|
|
1290
|
+
while (queue.length) {
|
|
1291
|
+
const cur = queue.shift();
|
|
1292
|
+
if (visited.has(cur.id)) continue;
|
|
1293
|
+
visited.add(cur.id);
|
|
1294
|
+
const node = byId.get(cur.id);
|
|
1295
|
+
if (!node) continue;
|
|
1296
|
+
if (cur.depth > 0) result.push({ id: cur.id, title: node.title, depth: cur.depth, via: cur.via, files: node.files, errorModes: node.errorModes });
|
|
1297
|
+
for (const next of node.affects || []) queue.push({ id: next, depth: cur.depth + 1, via: 'affects' });
|
|
1298
|
+
for (const next of node.coChangesWith || []) queue.push({ id: next, depth: cur.depth + 1, via: 'co-changes-with' });
|
|
1299
|
+
}
|
|
1300
|
+
// 역방향: 이 feature에 depends-on 하는 노드도 영향받음
|
|
1301
|
+
for (const n of nodes) {
|
|
1302
|
+
if (visited.has(n.id)) continue;
|
|
1303
|
+
if ((n.dependsOn || []).includes(startId)) {
|
|
1304
|
+
result.push({ id: n.id, title: n.title, depth: 1, via: 'depends-on(reverse)', files: n.files, errorModes: n.errorModes });
|
|
1305
|
+
visited.add(n.id);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return result;
|
|
1309
|
+
}
|
|
1310
|
+
// 1.9.393 (UR-0025): CHANGELOG 버전 구간 차분 파서 — from < V <= to 섹션 + 신규 명령/플래그/파일 추출. 순수. harness(update/whats-new) 공유.
|
|
1311
|
+
// BUG-fix(1.9.393): (1) 헤더 꼬리가 '## X — DATE — title' 의 ' — title' 를 소비 못 해 0건 반환 → 헤더에 '[^\n]*' 허용.
|
|
1312
|
+
// (2) 기존 본문 캡처 '([\s\S]*?)(?=^##…|$)' 가 /m 모드 '$'(줄 끝) 때문에 본문을 첫 줄로 절단 → _parseFeatureGraph 식 '위치 기반 분할'로 교체.
|
|
1313
|
+
// '## X'(제목 없음) / '## X — DATE' / '## X — DATE — title' 모두 매칭, 본문은 다음 헤더(또는 끝)까지 전체 캡처.
|
|
1314
|
+
function _parseChangelogBetween(changelogText, fromV, toV) {
|
|
1315
|
+
const text = changelogText || '';
|
|
1316
|
+
const headerRe = /^## (\d+\.\d+\.\d+)(?:\s+—\s+(\d{4}-\d{2}-\d{2}))?[^\n]*$/gm;
|
|
1317
|
+
const positions = [];
|
|
1318
|
+
let hm;
|
|
1319
|
+
while ((hm = headerRe.exec(text)) !== null) positions.push({ version: hm[1], date: hm[2] || null, start: hm.index, bodyStart: hm.index + hm[0].length });
|
|
1320
|
+
const sections = [];
|
|
1321
|
+
for (let i = 0; i < positions.length; i++) {
|
|
1322
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : text.length;
|
|
1323
|
+
sections.push({ version: positions[i].version, date: positions[i].date, body: text.slice(positions[i].bodyStart, end).trim() });
|
|
1324
|
+
}
|
|
1325
|
+
// from < V <= to 만 (fromV 자체는 이미 적용된 버전이므로 제외)
|
|
1326
|
+
const ranged = sections.filter(s => {
|
|
1327
|
+
const cmp = (v1, v2) => {
|
|
1328
|
+
const a = v1.split('.').map(Number), b = v2.split('.').map(Number);
|
|
1329
|
+
for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] - b[i]; }
|
|
1330
|
+
return 0;
|
|
1331
|
+
};
|
|
1332
|
+
return cmp(s.version, fromV) > 0 && cmp(s.version, toV) <= 0;
|
|
1333
|
+
});
|
|
1334
|
+
// 각 섹션에서 신규 명령/플래그/파일 추출
|
|
1335
|
+
for (const s of ranged) {
|
|
1336
|
+
s.newCommands = [];
|
|
1337
|
+
s.newFlags = [];
|
|
1338
|
+
s.newFiles = [];
|
|
1339
|
+
// `leerness X [...]` 또는 backtick에 싸인 leerness 명령
|
|
1340
|
+
for (const cm of s.body.matchAll(/`leerness\s+([a-z][\w-]*(?:\s+[a-z][\w-]*)?)/g)) {
|
|
1341
|
+
const cmd = cm[1].trim();
|
|
1342
|
+
if (!s.newCommands.includes(cmd)) s.newCommands.push(cmd);
|
|
1343
|
+
}
|
|
1344
|
+
// `--xxx` 플래그
|
|
1345
|
+
for (const fm of s.body.matchAll(/`(--[a-z][\w-]*)`/g)) {
|
|
1346
|
+
if (!s.newFlags.includes(fm[1])) s.newFlags.push(fm[1]);
|
|
1347
|
+
}
|
|
1348
|
+
// .harness/X.md 같은 신규 파일
|
|
1349
|
+
for (const ff of s.body.matchAll(/`(\.harness\/[\w./-]+\.(?:md|json|jsonl))`/g)) {
|
|
1350
|
+
if (!s.newFiles.includes(ff[1])) s.newFiles.push(ff[1]);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return ranged;
|
|
1354
|
+
}
|
|
1355
|
+
// 1.9.399 (7번째 버그헌트 P1-A, UR-0104): markdown 테이블 셀 안전화 — 개행(행 주입)·파이프(컬럼 시프트) 차단.
|
|
1356
|
+
// _cellSafe: 쓰기 시 개행→공백, '|'→'\|'(이스케이프). _cellUnescape: 읽기 시 '\|'→'|' 복원.
|
|
1357
|
+
// table 파서는 split(/(?<!\\)\|/) 로 비이스케이프 파이프에서만 분리 → 사용자 텍스트의 파이프/개행이 데이터 손상·가짜행 주입을 못 일으킴.
|
|
1358
|
+
function _cellSafe(s) { return String(s == null ? '' : s).replace(/\r\n|\r|\n/g, ' ').replace(/\|/g, '\\|'); }
|
|
1359
|
+
function _cellUnescape(s) { return String(s == null ? '' : s).replace(/\\\|/g, '|'); }
|
|
1360
|
+
// 1.9.402 (7번째 버그헌트 P1-A 잔여, UR-0108): 라인 안전화 — 개행만 공백으로(파이프 보존). decisions/lessons MD projection 의 '### '/'- field:' 라인 개행 주입 차단(canonical JSON 은 raw 유지).
|
|
1361
|
+
function _lineSafe(s) { return String(s == null ? '' : s).replace(/\r\n|\r|\n/g, ' '); }
|
|
1362
|
+
// 1.9.407 (8번째 버그헌트, UR-0111): --limit 안전 파싱 — NaN(예: '--limit abc')/음수/0 은 slice(0,NaN)=[] 로 모든 결과를 조용히 숨김 → 기본값으로 폴백.
|
|
1363
|
+
function _parseLimit(raw, def) { const n = parseInt(raw, 10); return (Number.isFinite(n) && n > 0) ? n : def; }
|
|
1364
|
+
|
|
1365
|
+
// 1.9.446 (R-0011/UR-0160): npm 배포 minor-gate. current(현재 버전) vs published(npm latest) 의 major.minor 비교.
|
|
1366
|
+
// minor 가 올라갔으면(또는 최초/major↑) publish, 같은 minor 내 patch 면 skip. force 면 무조건 publish.
|
|
1367
|
+
function _minorKey(v) { const m = String(v || '').match(/^(\d+)\.(\d+)/); return m ? `${m[1]}.${m[2]}` : null; }
|
|
1368
|
+
function _shouldPublishNpm(current, published, force) {
|
|
1369
|
+
if (force) return { publish: true, reason: 'forced' };
|
|
1370
|
+
const cm = String(current || '').match(/^(\d+)\.(\d+)/);
|
|
1371
|
+
if (!cm) return { publish: false, reason: 'invalid_current' };
|
|
1372
|
+
const pm = String(published || '').match(/^(\d+)\.(\d+)/);
|
|
1373
|
+
if (!pm) return { publish: true, reason: 'no_published' }; // 최초 배포
|
|
1374
|
+
const c = [Number(cm[1]), Number(cm[2])], p = [Number(pm[1]), Number(pm[2])];
|
|
1375
|
+
if (c[0] > p[0] || (c[0] === p[0] && c[1] > p[1])) return { publish: true, reason: 'minor_bump' }; // major/minor ↑
|
|
1376
|
+
if (c[0] === p[0] && c[1] === p[1]) return { publish: false, reason: 'same_minor' }; // patch — 미배포
|
|
1377
|
+
return { publish: false, reason: 'not_ahead' }; // 동일/하위
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// 1.9.416 (9th 외부평가 Sonnet/Codex, UR-0122): add 류(task/requests/decision) 제목 파싱 단일 출처.
|
|
1381
|
+
// positional 을 join 하되 첫 --flag 또는 경로형 토큰(/x, C:\x, ./x, ../x)에서 멈춤 →
|
|
1382
|
+
// `task add "제목" /some/path` 가 경로를 제목에 흡수하던 오염(decision add 는 이미 차단)을 일관 적용.
|
|
1383
|
+
function _parseAddTitle(args, startIdx = 0) {
|
|
1384
|
+
const parts = [];
|
|
1385
|
+
for (let i = startIdx; i < (args || []).length; i++) {
|
|
1386
|
+
const a = args[i];
|
|
1387
|
+
if (typeof a !== 'string') break;
|
|
1388
|
+
if (a.startsWith('--') || /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(a)) break;
|
|
1389
|
+
parts.push(a);
|
|
1390
|
+
}
|
|
1391
|
+
return parts.join(' ').trim();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// 1.9.442 (12th 외부평가 Sonnet UR-0141): task 계열 positional path 안전 추출.
|
|
1395
|
+
// _parseAddTitle 과 동일한 path-like 판정(선행 구분자 / ./ ../ C:\)으로 제목/ID/맨이름은 경로로 오인 안 함(src/auth 같은 내부 슬래시 제목 보호).
|
|
1396
|
+
// 값-취하는 플래그(--evidence /abs/log 등)의 값은 root 후보에서 제외(직전 토큰이 값-플래그면 skip) → 오탐 차단. 첫 path-like positional 만 반환, 없으면 null.
|
|
1397
|
+
const _TASK_VALUE_FLAGS = new Set(['--status', '--evidence', '--priority', '--note', '--reason', '--title', '--desc', '--summary', '--id', '--limit', '--from', '--to', '--trigger', '--tag']); // 1.9.445 (UR-0151): rule/lesson add 값-플래그(--trigger/--tag) 포함
|
|
1398
|
+
function _taskPositionalPath(args, startIdx = 2) {
|
|
1399
|
+
const a = args || [];
|
|
1400
|
+
for (let i = startIdx; i < a.length; i++) {
|
|
1401
|
+
if (typeof a[i] !== 'string') continue;
|
|
1402
|
+
if (_TASK_VALUE_FLAGS.has(a[i - 1])) continue; // 값-플래그의 값(예: --evidence /abs) 은 경로 아님
|
|
1403
|
+
if (a[i].startsWith('-')) continue; // 플래그 자체 제외
|
|
1404
|
+
if (/^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(a[i])) return a[i]; // 선행 구분자 path-like 만
|
|
1405
|
+
}
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|