leerness 1.36.53 → 1.36.55
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 +23 -0
- package/README.md +4 -4
- package/bin/leerness.js +51 -6
- package/lib/clarify.js +34 -10
- package/lib/io.js +8 -4
- package/lib/tech-profile.js +30 -6
- package/package.json +1 -1
- package/scripts/e2e.js +35 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.55 — 2026-07-21 — E2E 아티팩트 이식성: 게시본에서 release gate 재현 (외부 GPT 감사 P0-2)
|
|
4
|
+
|
|
5
|
+
감사 지적: 게시 npm 아티팩트에서 e2e 6건이 저장소-전용 픽스처로 실패 — "release gate 가 아티팩트에서 재현 안 되는 것은 릴리스 엔지니어링 결함". 6건 전부 자기완결화.
|
|
6
|
+
|
|
7
|
+
- **.gitignore/.npmignore 검사**: npm files 화이트리스트가 의도적으로 제외하는 저장소-전용 파일 — 부재 시 명시 `⊘ SKIP(아티팩트 프로필)` (조용한 누락 아님, total 미포함).
|
|
8
|
+
- **skillpack 동적 로드**: 형제 디렉토리(../leerness-skillpack) 가정 제거 — 없으면 최소 catalog.json 픽스처를 테스트가 직접 생성.
|
|
9
|
+
- **Codex 수렴(require-evidence) · capabilities/team 영어화**: auto 로케일(en 환경)에서 ko 정규식 단언이 깨지던 비결정성 — 모든 i18n 케이스에 `--language ko|en` 명시 고정.
|
|
10
|
+
- **agents dispatch flag bleed**: 실제 codex CLI 설치 가정 제거 — 즉시 종료하는 fake codex 실행파일(sh/cmd)을 temp PATH 앞에 주입.
|
|
11
|
+
- 검증: `npm pack` 아티팩트를 풀어 그 안에서 e2e 전체 실행(감사와 동일 방식) — 저장소 실행과 양쪽 그린.
|
|
12
|
+
|
|
13
|
+
## 1.36.54 — 2026-07-21 — codex 7차 헌트: 신작 표면(1.36.49~53) 12건 전수 수정 (High 2 · Med 7 · Low 3)
|
|
14
|
+
|
|
15
|
+
직전 3개 릴리스에서 신설한 기능(clarify/preview/tech/그래프 탭/io v2)을 외부 적대 검증 — 12건 발견, 전수 독립 재현 후 전부 수정 (반박 0).
|
|
16
|
+
|
|
17
|
+
- **#1 High — preview 형상-무효 스토어 파괴/크래시**: 파싱되지만 무효(비배열 루트/null 항목)를 []로 오인해 덮어씀(6차 rules 와 동일 클래스 재발!) — 로더 형상검증 + 변경 진입점 store_invalid 거부.
|
|
18
|
+
- **#2 High — 동시 preview add lost-update**: 락 없는 read-modify-write — _withLock 직렬화(락 안 재로드). 실측: 동시 2건 모두 생존·고유 ID.
|
|
19
|
+
- **#3 — clarify FP/FN**: "A 또는 B 모두 테스트" 오질문(모두/둘다/both 억제) + "좀 더 좋게" 미감지(비교급 모호 패턴 추가).
|
|
20
|
+
- **#4 — DATABASE_URL→postgres 오단정**: DB 중립 키 제외(.env 값 안 읽는 원칙 유지). **#5 — 모노레포 하위 매니페스트 미감지**: 2단계 병합 스캔. **#6 — 스키마-무효 프로필 고착**: {} 를 이전 프로필로 오인해 영구 미갱신 — 부재 취급 재생성. **#12 — requirements 점-접두 오탐**(openai.fake→openai): 배포명 전체 일치.
|
|
21
|
+
- **#7 — io 계약 모순 2종**: failJson 후 log(JSON) 이중 문서(래치 후 JSON 통과는 지연-fail 전용) + fail 후 회복 exit 0 인데 폴백 오류 JSON(최종 exit≠0 일 때만 방출). **#8 — 같은 ms 백업명 충돌**: pid+시퀀스 접미.
|
|
22
|
+
- **#9 — 동등 주장의 표현별 판정 불일치**: 실행-우선(1.36.52)을 비율형만 적용해 "3 tests passed"와 "3/3 passed"가 갈렸다 — 카운트형에도 동일 적용(실행 미확증 정적 게이트는 유지).
|
|
23
|
+
- **#10 — approve 중복 이력/빈 revise**: 멱등 no-op + note_required. **#11 — 제목 개행**: 한 줄 정규화.
|
|
24
|
+
- 검증: 12/12 행위 재검 실측(동시성·형상·io 3종·양방향 verify-claim 포함), selftest 328(+1 통합), e2e.
|
|
25
|
+
|
|
3
26
|
## 1.36.53 — 2026-07-21 — 기술 프로필: 언어·서비스 자동 감지 + 마이그레이션 이력 + 그래프 🛠 탭 (사용자 요청 UR-0062)
|
|
4
27
|
|
|
5
28
|
사용자 요청: "어떤 언어로 개발 중인지·어떤 서비스에 연결돼 있는지(마이그레이션/언어 전환 반영) 온톨로지 그래프에 표시, 그래프의 토글 스위치는 제거하고 CLI 명령/사용가이드로 대체".
|
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.55 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
126
126
|
|
|
127
127
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
128
128
|
|
|
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
|
|
|
176
176
|
|
|
177
177
|
### MCP server (외부 AI 통합)
|
|
178
178
|
|
|
179
|
-
Leerness v1.36.
|
|
179
|
+
Leerness v1.36.55는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
|
|
180
180
|
|
|
181
181
|
```jsonc
|
|
182
182
|
// 카테고리별
|
|
@@ -197,7 +197,7 @@ Leerness v1.36.53는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
|
|
|
197
197
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
198
198
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
199
199
|
|
|
200
|
-
현재 누적: **v1.9.x → 1.36.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.55 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,6 +235,6 @@ leerness release pack --close --auto-main-push
|
|
|
235
235
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
236
236
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
237
237
|
|
|
238
|
-
Last synced by Leerness v1.36.
|
|
238
|
+
Last synced by Leerness v1.36.55: 2026-07-21
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
|
240
240
|
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.55';
|
|
38
38
|
|
|
39
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -5083,6 +5083,49 @@ function _selfTestCases() {
|
|
|
5083
5083
|
const tabOk = html.includes('data-v="tech"') && !html.includes('tgFlip') && html.includes('leerness toggle set') && html.includes('firebase');
|
|
5084
5084
|
return det && saved && migr && tabOk;
|
|
5085
5085
|
} finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
5086
|
+
} },
|
|
5087
|
+
{ name: 'codex 7차 헌트 12종 (1.36.54): preview 형상검증/락·clarify 억제/보강·tech DATABASE_URL 제외/모노레포/스키마검증/점-접두·io 이중JSON/폴백exit/백업충돌·count 실행우선 — 행위+소스가드', run: () => {
|
|
5088
|
+
const c = require('../lib/clarify');
|
|
5089
|
+
// #3 행위: 모두-표지 억제 + 비교급 모호 감지
|
|
5090
|
+
const r1 = c._clarifySignals('A 또는 B 케이스 모두 테스트해줘');
|
|
5091
|
+
const r2 = c._clarifySignals('좀 더 좋게 해줘');
|
|
5092
|
+
const clar = r1.ambiguous === false && r2.ambiguous === true;
|
|
5093
|
+
// #1 행위: 형상-무효 로더 판정
|
|
5094
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_pv54_'));
|
|
5095
|
+
let shape = false;
|
|
5096
|
+
try {
|
|
5097
|
+
fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
|
|
5098
|
+
fs.writeFileSync(c._previewsPath(tmp), '{"legacy":{"id":"P-0042"}}');
|
|
5099
|
+
const chk1 = c._loadPreviewsChecked(tmp);
|
|
5100
|
+
fs.writeFileSync(c._previewsPath(tmp), '[{"id":"P-0001","title":"ok"},null]');
|
|
5101
|
+
const chk2 = c._loadPreviewsChecked(tmp);
|
|
5102
|
+
fs.writeFileSync(c._previewsPath(tmp), '[{"id":"P-0001","title":"ok"}]');
|
|
5103
|
+
const chk3 = c._loadPreviewsChecked(tmp);
|
|
5104
|
+
shape = chk1.invalid && chk2.invalid && !chk3.invalid && chk3.list.length === 1;
|
|
5105
|
+
} finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
5106
|
+
// #4/#12 행위: DATABASE_URL 제외 + 점-접두 오탐 차단
|
|
5107
|
+
const tp = require('../lib/tech-profile');
|
|
5108
|
+
const pgSig = tp.SERVICE_SIGNALS.find(s => s.id === 'postgres');
|
|
5109
|
+
const noDbUrl = !pgSig.env.includes('DATABASE_URL');
|
|
5110
|
+
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_tp54_'));
|
|
5111
|
+
let reqOk = false;
|
|
5112
|
+
try {
|
|
5113
|
+
fs.mkdirSync(path.join(tmp2, '.harness'), { recursive: true });
|
|
5114
|
+
fs.writeFileSync(path.join(tmp2, 'requirements.txt'), 'openai.fake==1.0\ndiscord.js==2.0\n');
|
|
5115
|
+
const det = tp.detectTechProfile(tmp2);
|
|
5116
|
+
const ids = det.services.map(s => s.id);
|
|
5117
|
+
reqOk = !ids.includes('openai') && ids.includes('discord');
|
|
5118
|
+
} finally { try { fs.rmSync(tmp2, { recursive: true, force: true }); } catch {} }
|
|
5119
|
+
// 소스가드: 락 직렬화(#2)·스키마검증(#6)·이중JSON 차단(#7a)·폴백 exit 게이트(#7b)·백업 접미(#8)·count 실행우선(#9)·note_required(#10)
|
|
5120
|
+
const s = read(__filename);
|
|
5121
|
+
const cl = read(path.join(__dirname, '..', 'lib', 'clarify.js'));
|
|
5122
|
+
const io2 = read(path.join(__dirname, '..', 'lib', 'io.js'));
|
|
5123
|
+
const tps = read(path.join(__dirname, '..', 'lib', 'tech-profile.js'));
|
|
5124
|
+
const guards = cl.includes('_withLock(_previewsPath(root)') && cl.includes("failJson(json, 'note_required'")
|
|
5125
|
+
&& tps.includes('prev = null') && tps.includes('_mergeManifests(root, 2)')
|
|
5126
|
+
&& io2.includes('!_jsonErrEmitted && t && (t[0]') && /process\.on\('exit', \(code\) => \{ if \(code !== 0/.test(io2) && io2.includes('.corrupt-${Date.now()}-${process.pid}-')
|
|
5127
|
+
&& s.includes('declaredTestCount != null && runResult && !runResult.skipped && runResult.allPassed');
|
|
5128
|
+
return clar && shape && noDbUrl && reqOk && guards;
|
|
5086
5129
|
} }
|
|
5087
5130
|
];
|
|
5088
5131
|
}
|
|
@@ -12359,10 +12402,12 @@ function verifyClaimCmd(root, taskId, opts = {}) {
|
|
|
12359
12402
|
// 1.9.309 (UR-0048, 설치리뷰 Opus critical): done 주장은 evidence(파일+테스트) 기본 강제 — 거짓완료 차단을 기본값·MCP·json 모두에서 작동.
|
|
12360
12403
|
// 이전: --require-evidence 플래그 시에만 검사 → 증거0 done 이 기본 통과(fileChecks.every([])=공허참)했고 MCP 는 도달 불가했음.
|
|
12361
12404
|
// 이제 done/완료 주장은 기본 강제. opt-out: --lenient. 비-done 주장은 영향 없음.
|
|
12362
|
-
// 1.36.52 (F-02 보정, 게이트 실측):
|
|
12363
|
-
// 실제 실행으로 확증하면
|
|
12364
|
-
//
|
|
12365
|
-
|
|
12405
|
+
// 1.36.52 (F-02 보정, 게이트 실측): 정적 선언-수 대조는 실행 확증이 없을 때의 휴리스틱 — --run-tests 가 주장 수
|
|
12406
|
+
// 이상 전부-통과를 실제 실행으로 확증하면 재적용하지 않는다(실행 증거 > 정적 휴리스틱).
|
|
12407
|
+
// 1.36.54 (codex 7차 #9): 비율형(_ratioPromoted)만이 아니라 카운트형("N tests passed")도 동일 우선순위 —
|
|
12408
|
+
// 같은 주장을 표현만 바꿨을 때 판정이 갈리던 비일관성 해소. void _ratioPromoted (표시용으로만 유지).
|
|
12409
|
+
void _ratioPromoted;
|
|
12410
|
+
if (declaredTestCount != null && runResult && !runResult.skipped && runResult.allPassed && runResult.parsed && runResult.parsed.num >= declaredTestCount) declaredTestCount = null;
|
|
12366
12411
|
const isDoneClaim = /done|완료|completed/i.test(row.status || '');
|
|
12367
12412
|
const evq = _evidenceQuality(evidence);
|
|
12368
12413
|
const mustHaveEvidence = !has('--lenient') && (isDoneClaim || has('--require-evidence'));
|
|
@@ -22571,7 +22616,7 @@ async function main() {
|
|
|
22571
22616
|
if (_pvRaw[i].startsWith('--')) { if (['--design', '--features', '--note', '--path'].includes(_pvRaw[i]) && _pvRaw[i + 1]) i++; continue; }
|
|
22572
22617
|
_pvToks.push(_pvRaw[i]);
|
|
22573
22618
|
}
|
|
22574
|
-
return _clar.previewCmd(arg('--path', process.cwd()), _pvToks[0], _pvToks.slice(1), { has, arg });
|
|
22619
|
+
return _clar.previewCmd(arg('--path', process.cwd()), _pvToks[0], _pvToks.slice(1), { has, arg, _withLock }); // 1.36.54 (#2): 변경은 락 직렬화
|
|
22575
22620
|
}
|
|
22576
22621
|
if (cmd === 'lens') return lensCmd(args[1]); // 1.18.3 (UR-0003): 분야별 자기질문 품질 렌즈
|
|
22577
22622
|
// 1.9.233: leerness commands — 카테고리화된 전체 CLI 명령 목록
|
package/lib/clarify.js
CHANGED
|
@@ -10,11 +10,12 @@ const { absRoot, exists, read, writeUtf8, log, ok, warn, fail, failJson, now, to
|
|
|
10
10
|
// ── ① 모호성 신호 카탈로그 (kind → { re, q(match) }) ──────────────────────────
|
|
11
11
|
const CLARIFY_SIGNALS = [
|
|
12
12
|
// '잘'은 "잘 통과하는지" 같은 정상 문장에 흔해 뒤따르는 요청동사가 있을 때만 (false-PASS 편향)
|
|
13
|
-
{ kind: 'vague-quality', re: /(적당히|알아서|이쁘게|예쁘게|깔끔하게|멋지게|잘\s?(?:좀|만들|해줘|해 줘|부탁)|as appropriate|make it nice|make it pretty)/,
|
|
13
|
+
{ kind: 'vague-quality', re: /(적당히|알아서|이쁘게|예쁘게|깔끔하게|멋지게|잘\s?(?:좀|만들|해줘|해 줘|부탁)|(?:좀 ?)?더 (?:좋게|낫게|이쁘게|예쁘게|깔끔하게)|as appropriate|make it nice|make it pretty|make it better)/,
|
|
14
14
|
q: (m) => `「${m.trim()}」의 기대 수준이 불명확합니다 — 참고할 예시(스크린샷/사이트/기존 화면)나 구체 기준이 있나요?` },
|
|
15
15
|
{ kind: 'pronoun', re: /(그거|이거|저거|아까\s?그|그 부분|위에서 말한|the one before)/,
|
|
16
16
|
q: (m) => `「${m.trim()}」가 무엇을 가리키는지 확인이 필요합니다 — 대상(파일/화면/기능 이름)을 지정해 주세요.` },
|
|
17
|
-
|
|
17
|
+
// 1.36.54 (codex 7차 #3): "모두/둘 다/both" 가 이미 답을 준 문장은 선택지 질문 억제(suppressIf) — false-BLOCK 방지
|
|
18
|
+
{ kind: 'alternative', re: /(\S+)\s?(?:이나|나|또는|혹은)\s(\S+)|(\b\w+\b) or (\b\w+\b)/, suppressIf: /(모두|둘 ?다|양쪽|전부|both|all of)/,
|
|
18
19
|
q: (m) => `복수 선택지(${m.trim()})가 언급됐습니다 — 어느 쪽을 원하시나요, 아니면 둘 다인가요?` },
|
|
19
20
|
{ kind: 'vague-scope', re: /(전부 다|전부|모두 다|모든 걸|싹 다|everything|all of (?:it|them))/,
|
|
20
21
|
q: (m) => `「${m.trim()}」의 범위가 넓습니다 — 포함/제외할 대상을 구체적으로 확인해도 될까요?` },
|
|
@@ -30,7 +31,7 @@ function _clarifySignals(text) {
|
|
|
30
31
|
const signals = [];
|
|
31
32
|
for (const s of CLARIFY_SIGNALS) {
|
|
32
33
|
const m = t.match(s.re);
|
|
33
|
-
if (m) signals.push({ kind: s.kind, match: m[0], question: s.q(m[0]) });
|
|
34
|
+
if (m && !(s.suppressIf && s.suppressIf.test(t))) signals.push({ kind: s.kind, match: m[0], question: s.q(m[0]) });
|
|
34
35
|
}
|
|
35
36
|
return { ambiguous: signals.length > 0, signals, questions: signals.map(s => s.question) };
|
|
36
37
|
}
|
|
@@ -52,11 +53,18 @@ function clarifyCmd(root, text, deps = {}) {
|
|
|
52
53
|
// ── ② 미리보기 승인 스토어 ─────────────────────────────────────────────────
|
|
53
54
|
function _previewsPath(root) { return path.join(absRoot(root), '.harness', 'previews.json'); }
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// 1.36.54 (codex 7차 #1 High): 파싱은 되지만 형상이 무효(루트 비배열/항목 null/비객체)인 스토어를
|
|
57
|
+
// []로 오인해 덮어쓰던 클래스(6차 rules R-id 와 동일) — 로더가 무효 여부를 함께 반환하고 변경 진입점은 거부.
|
|
58
|
+
function _loadPreviewsChecked(root) {
|
|
56
59
|
const f = _previewsPath(root);
|
|
57
|
-
if (!exists(f)) return [];
|
|
58
|
-
try {
|
|
60
|
+
if (!exists(f)) return { list: [], invalid: false };
|
|
61
|
+
try {
|
|
62
|
+
const j = JSON.parse(read(f));
|
|
63
|
+
if (!Array.isArray(j) || j.some(e => !e || typeof e !== 'object' || typeof e.id !== 'string')) return { list: [], invalid: true };
|
|
64
|
+
return { list: j, invalid: false };
|
|
65
|
+
} catch { return { list: [], invalid: true }; }
|
|
59
66
|
}
|
|
67
|
+
function _loadPreviews(root) { return _loadPreviewsChecked(root).list; }
|
|
60
68
|
|
|
61
69
|
function _savePreviews(root, list, json) {
|
|
62
70
|
const f = _previewsPath(root);
|
|
@@ -80,11 +88,20 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
80
88
|
root = absRoot(root);
|
|
81
89
|
const json = !!(has && has('--json'));
|
|
82
90
|
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
|
|
83
|
-
const list = _loadPreviews(root);
|
|
84
91
|
sub = sub || 'list';
|
|
92
|
+
// 1.36.54 (#2 High): 변경 하위명령은 락 안에서 재로드→검증→저장 전체를 직렬화 — 동시 add 의 lost-update 차단.
|
|
93
|
+
const _mutating = ['add', 'approve', 'revise'].includes(sub);
|
|
94
|
+
if (_mutating && deps._withLock && !deps._locked) {
|
|
95
|
+
return deps._withLock(_previewsPath(root), () => previewCmd(root, sub, rest, Object.assign({}, deps, { _locked: true })));
|
|
96
|
+
}
|
|
97
|
+
const _chk = _loadPreviewsChecked(root);
|
|
98
|
+
// 1.36.54 (#1 High): 무효 형상 스토어 위 변경 거부 — 원본 보존 (읽기 명령은 빈 목록으로 관용)
|
|
99
|
+
if (_mutating && _chk.invalid) { failJson(json, 'store_invalid', `previews.json 형상 무효(비배열 루트 또는 null/ID 없는 항목) — 덮어쓰기 거부: ${_previewsPath(root)} (수동 복구 후 재시도)`); return; }
|
|
100
|
+
const list = _chk.list;
|
|
85
101
|
|
|
86
102
|
if (sub === 'add') {
|
|
87
|
-
|
|
103
|
+
// 1.36.54 (#11): 제목 개행 정규화 — 한 미리보기 = 목록 한 줄 불변식
|
|
104
|
+
const title = (rest || []).join(' ').replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
88
105
|
if (!title) { failJson(json, 'title_required', 'preview add "<기능 제목>" 필요 (+ --design "설명" --features "a,b")'); return; }
|
|
89
106
|
const design = arg ? (arg('--design', '') || '') : '';
|
|
90
107
|
const features = (arg ? (arg('--features', '') || '') : '').split(',').map(s => s.trim()).filter(Boolean);
|
|
@@ -128,6 +145,11 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
128
145
|
return;
|
|
129
146
|
}
|
|
130
147
|
if (sub === 'approve') {
|
|
148
|
+
// 1.36.54 (#10): 재승인은 멱등 no-op — 중복 이력 방지
|
|
149
|
+
if (p.status === 'approved') {
|
|
150
|
+
if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status, changed: false }, null, 2)); return; }
|
|
151
|
+
ok(`${p.id} 이미 승인됨 — 변경 없음`); return;
|
|
152
|
+
}
|
|
131
153
|
p.status = 'approved'; (p.history = p.history || []).push({ at: now(), event: 'approved' });
|
|
132
154
|
if (!_savePreviews(root, list, json)) return;
|
|
133
155
|
if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status }, null, 2)); return; }
|
|
@@ -135,7 +157,9 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
135
157
|
return;
|
|
136
158
|
}
|
|
137
159
|
if (sub === 'revise') {
|
|
138
|
-
const note = arg ? (arg('--note', '') || '') : '';
|
|
160
|
+
const note = (arg ? (arg('--note', '') || '') : '').toString().trim();
|
|
161
|
+
// 1.36.54 (#10): 빈 노트 수정요구 거부 — 무엇을 고칠지 없는 revise 는 워크플로 신호가 아니다
|
|
162
|
+
if (!note) { failJson(json, 'note_required', `preview revise ${p.id} --note "<수정 요구 내용>" 필요`); return; }
|
|
139
163
|
p.status = 'revision-requested'; (p.history = p.history || []).push({ at: now(), event: 'revision-requested', note });
|
|
140
164
|
if (!_savePreviews(root, list, json)) return;
|
|
141
165
|
if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status, note }, null, 2)); return; }
|
|
@@ -145,4 +169,4 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
145
169
|
failJson(json, 'unknown_subcommand', `알 수 없는 preview 하위명령: ${sub} (가능: add, list, show, approve, revise)`);
|
|
146
170
|
}
|
|
147
171
|
|
|
148
|
-
module.exports = { CLARIFY_SIGNALS, _clarifySignals, clarifyCmd, previewCmd, pendingPreviews, _previewsPath, _loadPreviews };
|
|
172
|
+
module.exports = { CLARIFY_SIGNALS, _clarifySignals, clarifyCmd, previewCmd, pendingPreviews, _previewsPath, _loadPreviews, _loadPreviewsChecked };
|
package/lib/io.js
CHANGED
|
@@ -14,9 +14,11 @@ function log(s = '') {
|
|
|
14
14
|
_unlatchIfNewCmd();
|
|
15
15
|
if (_quiet) return;
|
|
16
16
|
if (_failLatched || _jsonErrEmitted) {
|
|
17
|
-
// json-오류 래치 중: 명령 자신의 구조화 출력(JSON 페이로드)만 통과, 사람용 잔여 줄은 stderr
|
|
17
|
+
// json-오류 래치 중: 명령 자신의 구조화 출력(JSON 페이로드)만 통과, 사람용 잔여 줄은 stderr 강등.
|
|
18
|
+
// 1.36.54 (codex 7차 #7a): JSON 통과는 지연-fail(폴백 대기) 경로 전용 — failJson 이 이미 오류 JSON 을
|
|
19
|
+
// 방출했다면(_jsonErrEmitted) 추가 JSON 은 이중 문서가 되므로 stderr 강등.
|
|
18
20
|
const t = typeof s === 'string' ? s.trim() : '';
|
|
19
|
-
if (t && (t[0] === '{' || t[0] === '[')) { try { JSON.parse(t); console.log(s); _stdoutWrote = true; return; } catch {} }
|
|
21
|
+
if (!_jsonErrEmitted && t && (t[0] === '{' || t[0] === '[')) { try { JSON.parse(t); console.log(s); _stdoutWrote = true; return; } catch {} }
|
|
20
22
|
console.error(s); return;
|
|
21
23
|
}
|
|
22
24
|
console.log(s); _stdoutWrote = true;
|
|
@@ -56,7 +58,8 @@ function fail(s) {
|
|
|
56
58
|
console.error('✗ ' + s);
|
|
57
59
|
if (!_exitHooked) {
|
|
58
60
|
_exitHooked = true;
|
|
59
|
-
|
|
61
|
+
// 1.36.54 (#7b): 최종 exit 이 0(명령이 회복)이면 폴백 오류 JSON 을 내지 않는다 — 성공 exit 에 ok:false 모순 방지
|
|
62
|
+
process.on('exit', (code) => { if (code !== 0 && _failLatched && !_stdoutWrote && _firstFail != null) console.log(JSON.stringify({ ok: false, error: _firstFail, code: 'error' }, null, 2)); });
|
|
60
63
|
}
|
|
61
64
|
process.exitCode = 1; return;
|
|
62
65
|
}
|
|
@@ -102,7 +105,8 @@ function _rescueCorruptJson(p, s) {
|
|
|
102
105
|
let old;
|
|
103
106
|
try { old = read(p); } catch { return; }
|
|
104
107
|
try { JSON.parse(old); return; } catch {} // 기존이 정상이면 통상 덮어쓰기
|
|
105
|
-
|
|
108
|
+
// 1.36.54 (#8): 같은 ms 내 연속 대피의 백업명 충돌 방지 — pid+시퀀스 접미
|
|
109
|
+
const bak = `${p}.corrupt-${Date.now()}-${process.pid}-${++_writeSeq}`;
|
|
106
110
|
// 알림은 stderr — --json 명령의 stdout 단일-JSON 계약을 오염하지 않는다 (codex 스윕 B #1 실측)
|
|
107
111
|
try { fs.renameSync(p, bak); console.error(`⚠ 손상 JSON 감지 — 원본 대피 후 저장: ${path.basename(bak)} (수동 복구용)`); } catch {}
|
|
108
112
|
}
|
package/lib/tech-profile.js
CHANGED
|
@@ -35,7 +35,8 @@ const SERVICE_SIGNALS = [
|
|
|
35
35
|
{ id: 'vercel', deps: ['vercel'], env: ['VERCEL_'], files: ['vercel.json'] },
|
|
36
36
|
{ id: 'netlify', deps: ['netlify-cli'], env: ['NETLIFY_'], files: ['netlify.toml'] },
|
|
37
37
|
{ id: 'docker', deps: [], env: [], files: ['docker-compose.yml', 'docker-compose.yaml', 'Dockerfile'] },
|
|
38
|
-
|
|
38
|
+
// 1.36.54 (codex 7차 #4): DATABASE_URL 은 DB 중립 키(mysql:// 일 수도) — postgres 양성 신호에서 제외 (.env 값은 안 읽는 원칙 유지)
|
|
39
|
+
{ id: 'postgres', deps: ['pg', 'postgres'], env: ['POSTGRES_', 'PG_'] },
|
|
39
40
|
{ id: 'mysql', deps: ['mysql', 'mysql2'], env: ['MYSQL_'] },
|
|
40
41
|
{ id: 'mongodb', deps: ['mongodb', 'mongoose'], env: ['MONGO_', 'MONGODB_'] },
|
|
41
42
|
{ id: 'redis', deps: ['redis', 'ioredis'], env: ['REDIS_'] },
|
|
@@ -83,16 +84,36 @@ function detectTechProfile(root) {
|
|
|
83
84
|
}
|
|
84
85
|
const extCounts = _shallowExts(root);
|
|
85
86
|
for (const [lang, n] of Object.entries(extCounts)) if (n >= 3) addL(lang, `${n} source files`);
|
|
86
|
-
// deps 수집
|
|
87
|
+
// deps 수집 — 1.36.54 (#5): 모노레포 대응, 루트 + 얕은 하위(2단계) 매니페스트 병합 (vendor 류 제외)
|
|
87
88
|
let deps = {};
|
|
88
|
-
try { const pj = JSON.parse(read(path.join(root, 'package.json'))); deps = Object.assign({}, pj.dependencies, pj.devDependencies); } catch {}
|
|
89
89
|
let reqTxt = '';
|
|
90
|
-
|
|
90
|
+
const _mergeManifests = (dir, depth) => {
|
|
91
|
+
try {
|
|
92
|
+
const pj = JSON.parse(read(path.join(dir, 'package.json')));
|
|
93
|
+
deps = Object.assign({}, deps, pj.dependencies, pj.devDependencies);
|
|
94
|
+
if (dir !== root) addL('javascript', rel_(dir) + '/package.json');
|
|
95
|
+
} catch {}
|
|
96
|
+
try { if (exists(path.join(dir, 'requirements.txt'))) reqTxt += '\n' + read(path.join(dir, 'requirements.txt')); } catch {}
|
|
97
|
+
if (depth <= 0) return;
|
|
98
|
+
let entries = [];
|
|
99
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
100
|
+
for (const e of entries.slice(0, 200)) {
|
|
101
|
+
if (!e.isDirectory() || e.name.startsWith('.') || ['node_modules', 'dist', 'build', 'vendor', 'coverage'].includes(e.name)) continue;
|
|
102
|
+
_mergeManifests(path.join(dir, e.name), depth - 1);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const rel_ = (d) => path.relative(root, d).replace(/\\/g, '/');
|
|
106
|
+
_mergeManifests(root, 2);
|
|
91
107
|
const envKeys = _envKeys(root);
|
|
92
108
|
const services = []; const seenS = new Set();
|
|
93
109
|
const addS = (id, evidence) => { if (!seenS.has(id)) { seenS.add(id); services.push({ id, evidence }); } };
|
|
94
110
|
for (const s of SERVICE_SIGNALS) {
|
|
95
|
-
|
|
111
|
+
// 1.36.54 (#12): requirements 는 배포명 전체 일치만 — `\b` 가 점을 경계로 봐 openai.fake 가 openai 에 오탐했다.
|
|
112
|
+
// 이름 뒤는 버전 연산자/extras/공백/행끝만 허용 (PEP 스타일: 대소문자·-/_ 동치).
|
|
113
|
+
for (const d of (s.deps || [])) {
|
|
114
|
+
const reqHit = new RegExp(`^\\s*${d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/[-_]/g, '[-_]')}\\s*(?:[=<>!~;\\[]|$)`, 'mi').test(reqTxt);
|
|
115
|
+
if (deps[d] || reqHit) { addS(s.id, `dep: ${d}`); break; }
|
|
116
|
+
}
|
|
96
117
|
if (!seenS.has(s.id) && s.depPrefix) { const hit = Object.keys(deps).find(k => k.startsWith(s.depPrefix)); if (hit) addS(s.id, `dep: ${hit}`); }
|
|
97
118
|
if (!seenS.has(s.id)) for (const pre of (s.env || [])) { const hit = envKeys.find(k => k === pre || k.startsWith(pre)); if (hit) { addS(s.id, `env: ${hit}`); break; } }
|
|
98
119
|
if (!seenS.has(s.id)) for (const f of (s.files || [])) if (exists(path.join(root, f))) { addS(s.id, `file: ${f}`); break; }
|
|
@@ -113,7 +134,10 @@ function refreshTechProfile(root, opts = {}) {
|
|
|
113
134
|
root = absRoot(root);
|
|
114
135
|
if (!exists(path.join(root, '.harness'))) return null;
|
|
115
136
|
const cur = detectTechProfile(root);
|
|
116
|
-
|
|
137
|
+
let prev = loadTechProfile(root);
|
|
138
|
+
// 1.36.54 (#6): 파싱은 되지만 스키마가 무효(current/languages 부재)인 프로필은 부재 취급 — 종전엔 {} 가
|
|
139
|
+
// "이전 프로필"로 오인돼 changed=false 로 고착, 영원히 재기록/이력 축적이 안 됐다. (스키마-무효는 재생성 대상)
|
|
140
|
+
if (prev && (!prev.current || !Array.isArray(prev.current.languages) || !Array.isArray(prev.current.services))) prev = null;
|
|
117
141
|
const ids = a => (a || []).map(x => x.id);
|
|
118
142
|
const dif = (a, b) => a.filter(x => !b.includes(x));
|
|
119
143
|
let history = (prev && Array.isArray(prev.history)) ? prev.history : [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.55",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
package/scripts/e2e.js
CHANGED
|
@@ -1350,24 +1350,30 @@ total++;
|
|
|
1350
1350
|
if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
|
|
1351
1351
|
}
|
|
1352
1352
|
|
|
1353
|
-
|
|
1353
|
+
// 1.36.55 (외부감사 이식성): .gitignore/.npmignore 는 npm files 화이트리스트에서 의도적으로 제외되는 저장소-전용 파일 —
|
|
1354
|
+
// 게시 아티팩트에서는 검사 자체가 성립 안 하므로 명시 SKIP (조용한 누락이 아니라 사유 로그).
|
|
1354
1355
|
{
|
|
1355
|
-
// .gitignore에 _reports/ 포함 — leerness-pkg
|
|
1356
1356
|
const giPath = path.join(__dirname, '..', '.gitignore');
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1357
|
+
if (!fs.existsSync(giPath)) console.log('⊘ SKIP(아티팩트 프로필): leerness-pkg/.gitignore 검사 — 저장소 체크아웃 전용');
|
|
1358
|
+
else {
|
|
1359
|
+
total++;
|
|
1360
|
+
const body = fs.readFileSync(giPath, 'utf8');
|
|
1361
|
+
const ok = /_reports\//.test(body) && /\*\.private\.md/.test(body);
|
|
1362
|
+
console.log(ok ? '✓ B(1.9.43) leerness-pkg/.gitignore: _reports/ + *.private.md 차단' : `✗ .gitignore 실패`);
|
|
1363
|
+
if (!ok) { failed++; console.log(body.slice(0, 300)); }
|
|
1364
|
+
}
|
|
1361
1365
|
}
|
|
1362
1366
|
|
|
1363
|
-
total++;
|
|
1364
1367
|
{
|
|
1365
|
-
// .npmignore에 _reports/ + 보고서 차단 명시
|
|
1366
1368
|
const niPath = path.join(__dirname, '..', '.npmignore');
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1369
|
+
if (!fs.existsSync(niPath)) console.log('⊘ SKIP(아티팩트 프로필): leerness-pkg/.npmignore 검사 — 저장소 체크아웃 전용');
|
|
1370
|
+
else {
|
|
1371
|
+
total++;
|
|
1372
|
+
const body = fs.readFileSync(niPath, 'utf8');
|
|
1373
|
+
const ok = /_reports\//.test(body) && /\*\.private/.test(body);
|
|
1374
|
+
console.log(ok ? '✓ B(1.9.43) leerness-pkg/.npmignore: _reports/ 차단' : `✗ .npmignore 실패`);
|
|
1375
|
+
if (!ok) { failed++; console.log(body.slice(0, 300)); }
|
|
1376
|
+
}
|
|
1371
1377
|
}
|
|
1372
1378
|
|
|
1373
1379
|
// 1.9.42 회귀: agentskills.io 표준 호환 (skill install/discover/export + .env opt-in)
|
|
@@ -2347,9 +2353,17 @@ total++;
|
|
|
2347
2353
|
}
|
|
2348
2354
|
|
|
2349
2355
|
// 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
|
|
2356
|
+
// 1.36.55 (외부감사 이식성): 형제 디렉토리 가정 제거 — 없으면 최소 catalog.json 픽스처를 테스트가 직접 생성 (자기완결)
|
|
2350
2357
|
total++;
|
|
2351
2358
|
{
|
|
2352
|
-
|
|
2359
|
+
let skillpackDir = path.resolve(__dirname, '..', '..', 'leerness-skillpack');
|
|
2360
|
+
if (!fs.existsSync(path.join(skillpackDir, 'catalog.json'))) {
|
|
2361
|
+
skillpackDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-spfix-'));
|
|
2362
|
+
fs.writeFileSync(path.join(skillpackDir, 'catalog.json'), JSON.stringify({
|
|
2363
|
+
name: 'leerness-skillpack', version: '1.0.0', leernessCompat: '>=1.9.10', updatedAt: '2026-01-01',
|
|
2364
|
+
skills: [{ id: 'fixture-skill', displayNameKo: '픽스처 스킬', version: '1.0.0', lastUpdated: '2026-01-01', verification: 'passed', capabilities: ['e2e portable fixture'] }]
|
|
2365
|
+
}, null, 2));
|
|
2366
|
+
}
|
|
2353
2367
|
const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
|
|
2354
2368
|
encoding: 'utf8',
|
|
2355
2369
|
env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: skillpackDir })
|
|
@@ -3014,7 +3028,7 @@ total++;
|
|
|
3014
3028
|
{
|
|
3015
3029
|
const cDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-codex-'));
|
|
3016
3030
|
const env = { ...process.env, LEERNESS_OFFLINE: '1' };
|
|
3017
|
-
cp.spawnSync(process.execPath, [CLI, 'init', cDir, '--minimal', '--no-env', '--yes'], { encoding: 'utf8', timeout: 30000, env });
|
|
3031
|
+
cp.spawnSync(process.execPath, [CLI, 'init', cDir, '--minimal', '--no-env', '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000, env }); // 1.36.55: 로케일 고정 — auto(en 환경)에서 ko 정규식 단언이 깨지던 이식성 결함
|
|
3018
3032
|
// A) 허위 완료(테스트 통과만) → --require-evidence FAIL (exit 1)
|
|
3019
3033
|
cp.spawnSync(process.execPath, [CLI, 'task', 'add', 'fake done', '--path', cDir], { encoding: 'utf8', timeout: 15000, env });
|
|
3020
3034
|
cp.spawnSync(process.execPath, [CLI, 'task', 'update', 'T-0002', '--status', 'done', '--evidence', '테스트 통과함', '--path', cDir], { encoding: 'utf8', timeout: 15000, env });
|
|
@@ -6315,7 +6329,7 @@ total++;
|
|
|
6315
6329
|
const H = /[가-힣]/;
|
|
6316
6330
|
const cap = (args) => (cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 15000 }).stdout) || '';
|
|
6317
6331
|
const capEn = cap(['capabilities', '--language', 'en']);
|
|
6318
|
-
const capKo = cap(['capabilities']);
|
|
6332
|
+
const capKo = cap(['capabilities', '--language', 'ko']); // 1.36.55: ko 도 명시 — auto 로케일 환경 비결정성 제거
|
|
6319
6333
|
const capJsonEn = cap(['capabilities', '--json', '--language', 'en']);
|
|
6320
6334
|
// EN: zero Hangul + English catalog desc/note + English labels; KO: preserved; JSON: English principles
|
|
6321
6335
|
const capOk = !H.test(capEn) && /metadata files|external AI CLIs|mouse\/keyboard/.test(capEn)
|
|
@@ -6324,7 +6338,7 @@ total++;
|
|
|
6324
6338
|
&& /0 runtime dependencies/.test(capJsonEn);
|
|
6325
6339
|
// team reminder body en/ko via handoff (full wiring: pure lang + caller _uiLang)
|
|
6326
6340
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-team-i18n-'));
|
|
6327
|
-
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
|
|
6341
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 }); // 1.36.55: 로케일 고정
|
|
6328
6342
|
cp.spawnSync(process.execPath, [CLI, 'team', 'add', 'nightly', '--members', 'a,b', '--schedule', 'every-session', '--path', d], { encoding: 'utf8', timeout: 15000 });
|
|
6329
6343
|
const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', d, '--language', 'en'], { encoding: 'utf8', timeout: 30000 }).stdout) || '';
|
|
6330
6344
|
const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', d], { encoding: 'utf8', timeout: 30000 }).stdout) || '';
|
|
@@ -6593,7 +6607,11 @@ total++;
|
|
|
6593
6607
|
try {
|
|
6594
6608
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-disp-'));
|
|
6595
6609
|
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
|
|
6596
|
-
|
|
6610
|
+
// 1.36.55 (외부감사 이식성): 실제 codex 미설치 환경 자기완결 — 즉시 종료하는 fake codex 실행파일을 PATH 앞에 주입
|
|
6611
|
+
const fbin = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-fakecodex-'));
|
|
6612
|
+
if (process.platform === 'win32') fs.writeFileSync(path.join(fbin, 'codex.cmd'), '@echo fake-codex\r\n');
|
|
6613
|
+
else { fs.writeFileSync(path.join(fbin, 'codex'), '#!/bin/sh\necho fake-codex\n'); fs.chmodSync(path.join(fbin, 'codex'), 0o755); }
|
|
6614
|
+
const env = { ...process.env, LEERNESS_ENABLE_CODEX: '1', PATH: fbin + path.delimiter + process.env.PATH };
|
|
6597
6615
|
const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'REVIEWTASK', '--to', 'codex', '--path', d], { encoding: 'utf8', timeout: 20000, env });
|
|
6598
6616
|
const out = r.stdout || '';
|
|
6599
6617
|
// 1.35.6: 기본은 harness 브리프가 접두되므로 task 는 "... 작업: REVIEWTASK" 끝에 그대로(코덱스/경로 흡수 없음).
|