leerness 1.36.73 → 1.36.75
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 +24 -0
- package/README.md +4 -4
- package/bin/leerness.js +131 -39
- package/lib/clarify.js +111 -5
- package/package.json +1 -1
- package/scripts/e2e.js +132 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.75 — 2026-07-25 — UR-0066: 디자인 시안 우선 워크플로 (preview mockup) — R-0001 검수 18회전
|
|
4
|
+
|
|
5
|
+
사용자 요청: 웹페이지/디자인 작업은 코드 구현 전에 디자인 시안을 먼저 제시하고 수정/승인을 질문으로 받도록.
|
|
6
|
+
|
|
7
|
+
- **`leerness preview mockup <P-ID> [--force]`**: 자립형(오프라인·외부 리소스 0) HTML 시안 스캐폴드를 `.harness/previews/<P-ID>-mockup.html` 에 생성 — AI 가 placeholder 를 실제 레이아웃 초안으로 교체해 사용자에게 브라우저로 제시. 기존 파일은 덮어쓰지 않음(--force 만 재생성, 이력은 mockup-regenerated 로 구분).
|
|
8
|
+
- **`preview add ... --mockup <파일>`**: 이미 만든 시안 첨부(일반 파일 + 프로젝트 루트 안 검증). **디자인/페이지 작업 자동 감지** 시 시안 생성 안내 출력(false-PASS 편향 — 일반 기능 요청엔 강제 안 함).
|
|
9
|
+
- **AGENTS.md(ko/en) 계약 강화**: 신규 페이지·디자인 요청 → 시안 제시·승인 전 실제 코드 작성 금지.
|
|
10
|
+
- **검수 7건 반영**: (High) 조작된 preview id 의 previews 디렉토리 밖 쓰기 차단(P-\d{4,} 강제 + 경로 격리 이중 가드) · (#2) `--mockup` 값이 제목에 흡수 · (#3) 디렉토리/루트 밖 첨부 거부 · (#4) 보호된 기존 시안도 경로는 스토어에 기록 · (#5) **승인 후 시안 생성/재생성 거부**(승인이 낡는 것 방지 — revise 로 되돌린 뒤에만) · (Low×2) 재생성 이력 구분·help 갱신.
|
|
11
|
+
- 검증: XSS 이스케이프·덮어쓰기 보호·감지 힌트·후방 호환(구 항목 mockupPath 없음) 실측, selftest 334, e2e +1(11 단언).
|
|
12
|
+
|
|
13
|
+
## 1.36.74 — 2026-07-25 — codex 9차 홀리스틱 헌트: 미점검 표면 6건 수정 (P1 3·P2 3)
|
|
14
|
+
|
|
15
|
+
표면 회전 원칙에 따라 최근 라운드가 비추지 않은 사용자 데이터 표면(MCP 프로토콜·memory archive/restore·.leerness state·api-skill)을 헌트. 8건 보고 중 자체 재현 확정 6건 수정.
|
|
16
|
+
|
|
17
|
+
- **P1 `.leerness/state.json` 스키마 무효 통과**: 파싱은 되지만 형태가 무효인 파일(`{}`)이 손상 가드를 통과해 runCounter 를 0 으로 되돌리고 **기존 run 을 덮어썼다**(실측 original-goal → replacement-goal). 형태 검증도 fail-closed — 단 runs/ 가 비어 있으면 관대(없는 데이터를 지킬 필요 없음).
|
|
18
|
+
- **P1 memory restore 가 활성 중복 생성**: drop → 같은 제목 재생성 → restore 시 동일 제목 2건이 조용히 생겨 어느 쪽이 진짜인지 알 수 없었다. 충돌 기본 거부(`restore_conflict`) + `--force` 우회 — 복원은 되살리기지 복제가 아니다. archive 는 그대로 두어 재시도 가능.
|
|
19
|
+
- **P1 api-skill skeleton 경로 id 충돌**: fetch 경로에만 있던 충돌 해소기가 skeleton 폴백엔 없어 URL 이 다른데 slug 가 같으면 앞 항목을 통째로 덮어썼다(alpha URL·방향 소실 실측). 동일 규칙 공유.
|
|
20
|
+
- **P2 MCP 마지막 요청 유실**: 개행 없이 끝난 마지막 JSON-RPC 요청이 EOF 에서 조용히 버려졌다(실측: 무응답 exit 0). EOF 시 잔여 버퍼도 동일 경로로 처리.
|
|
21
|
+
- **P2 손상 api-skill 이 정상처럼 노출**: frontmatter 깨진 파일이 "URL 0개 정상 스킬"로 강등돼 목록에 섞이던 것 — `corrupt`/`corruptCount` 표기 + 텍스트 경고(로드 자체는 유지, 비파괴).
|
|
22
|
+
- **P2 `api-skill add --json` 계약 위반**(자체 관측): 사람용 진행 로그·실패 메시지가 stdout 을 오염시켜 JSON 소비자가 파싱 불가 — 진행 로그 억제 + 실패 구조화(`{ok:false, code:'fetch_failed'}`), drop/show 에러도 failJson 통일.
|
|
23
|
+
- **(R-0001 검수 17회전이 제 수정의 불완전성 2건을 차단)**: ① state 형태 검증만으론 부족 — **형태는 유효하나 stale 한 runCounter**(0 < 실재 run-0001)가 여전히 기존 run 을 덮어썼다(실측). 디스크 최대 run 번호와 대조해 fail-closed. ② restore 충돌 판정이 "첫 줄 substring"이라 **날짜만 다른 논리적 중복은 통과**시키고 **같은 날 무관한 lesson·'Deploy' vs 'Deploy v2' 는 오탐 차단**했다(양쪽 실측) — 표면별 정체성(decision=title/lesson=text/plan=제목) 파싱 정확 일치로 교체. (검수 Low) 빈 api-skill 저장소도 `corruptCount` 동일 형태.
|
|
24
|
+
- 미재현 이월: 잘린 UTF-8 아카이브 복원(#3)은 제 절단 변형에선 U+FFFD 미유입 — 정확한 바이트 조작 재현 후 별도 판단. MCP 티어/텔레메트리(#2)·긴 파생 id(#8)는 다음 라운드.
|
|
25
|
+
- 정상 확인: MCP 잘못된 JSON(-32700)·미지 도구(-32601)·연속 요청 프레이밍·1MiB 라인, state 동시 8start/12record 무손실, 손상 state 파싱 실패 fail-closed.
|
|
26
|
+
|
|
3
27
|
## 1.36.73 — 2026-07-25 — 8차 헌트 완전 종결: F12 skills-lock 정합 + F9 설계 결정
|
|
4
28
|
|
|
5
29
|
8차 홀리스틱 헌트(16건)의 마지막 설계 판단 2건을 결론 — 이로써 16/16 전건 처리 완료.
|
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.75 하네스를 사용합니다. 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.75는 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.73는 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.75 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,5 +235,5 @@ 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.75: 2026-07-25
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
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.75';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -548,7 +548,8 @@ function coreFiles(root, lang = 'ko', selectedSkills = [], opts = {}) {
|
|
|
548
548
|
const project = detectProjectName(root);
|
|
549
549
|
const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
|
|
550
550
|
const _files = {
|
|
551
|
-
'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## ⭐ 매 세션 첫 행동\n**반드시 \`.harness/session-workflow.md\`를 먼저 읽고 6단계 워크플로를 따른다**: 요청분석→계획→분배→sub-agent작업→종합검증→마감. 라운드 길이/복잡도 무관, drift 방지를 위해 모든 작업에 동일 흐름 유지.\n\n## 정적 vs 동적 — leerness 역할 경계\n**AGENTS.md = 정적 프로젝트 지침** (코딩 규칙·테스트 명령·금지 사항·배포 절차 — 자주 안 변함).\n**leerness = 동적 작업 상태·기억·검증·인수인계** (현재 목표·수정 파일·실패 시도·검증 결과·다음 에이전트 인계 — 매 작업 변함).\n- 규칙/명령/금지는 여기 AGENTS.md 에 적는다.\n- 동적 상태(결정/교훈/계획/진행/검증/인수인계)는 leerness 가 **기본 워크스페이스 \`.harness/\`** 에 기록한다 (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md). \`leerness handoff\` · \`decision add\` · \`lesson save\` 등이 여기에 쓴다.\n- (선택) \`leerness state show|start|record|verify|handoff\` (또는 MCP \`leerness_state_*\`) 의 JSON 상태 substrate 는 \`.leerness/\` (에이전트 간 인수인계 표준 — state 명령 사용 시 생성). 메인 워크스페이스(.harness)와 별개.\n- leerness 는 AGENTS.md 를 **대체하지 않고 보완**한다. 정적 지침은 여기, 동적 상태는 leerness.\n\n## Mandatory read order (session start)\n1. **.harness/session-workflow.md** (6단계 워크플로 — 최우선)\n2. .harness/context-routing.md\n3. .harness/session-handoff.md\n4. .harness/current-state.md\n5. .harness/plan.md\n6. .harness/progress-tracker.md\n7. .harness/guideline.md\n8. .harness/protected-files.md\n9. .harness/writeback-policy.md\n10. .harness/anti-lazy-work-policy.md\n11. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- **모호성 질문 의무**: 사용자 요청에 판단이 갈리는 부분(모호한 수식어/지시대명사/복수 선택지/불명확한 범위)이 있으면 **추측으로 구현하지 말고 먼저 사용자에게 질문**합니다. \`leerness clarify "<요청>"\` 이 감지한 질문 목록을 그대로 사용자에게 물어보세요. 신호가 없어도 스스로 판단이 갈리면 질문이 우선입니다.\n- **미리보기 승인 의무 (신규 기능)**: 사용자가 신규 기능 추가/구현을 요청하면 **코드를 먼저 작성하지 않습니다**. ① \`leerness preview add "<기능>" --design "<디자인/UX 설명>" --features "<기능 목록>"\` 으로 미리보기를 등록하고 ② 그 내용을 사용자에게 제시해 승인 또는 수정사항을 질문으로 받습니다. ③ 사용자가 승인하면 \`leerness preview approve <P-ID>\`, 수정 요구면 \`leerness preview revise <P-ID> --note "..."\` 후 미리보기를 고쳐 다시 제시합니다. **approve 전에는 해당 기능의 코드를 작성하지
|
|
551
|
+
'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## ⭐ 매 세션 첫 행동\n**반드시 \`.harness/session-workflow.md\`를 먼저 읽고 6단계 워크플로를 따른다**: 요청분석→계획→분배→sub-agent작업→종합검증→마감. 라운드 길이/복잡도 무관, drift 방지를 위해 모든 작업에 동일 흐름 유지.\n\n## 정적 vs 동적 — leerness 역할 경계\n**AGENTS.md = 정적 프로젝트 지침** (코딩 규칙·테스트 명령·금지 사항·배포 절차 — 자주 안 변함).\n**leerness = 동적 작업 상태·기억·검증·인수인계** (현재 목표·수정 파일·실패 시도·검증 결과·다음 에이전트 인계 — 매 작업 변함).\n- 규칙/명령/금지는 여기 AGENTS.md 에 적는다.\n- 동적 상태(결정/교훈/계획/진행/검증/인수인계)는 leerness 가 **기본 워크스페이스 \`.harness/\`** 에 기록한다 (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md). \`leerness handoff\` · \`decision add\` · \`lesson save\` 등이 여기에 쓴다.\n- (선택) \`leerness state show|start|record|verify|handoff\` (또는 MCP \`leerness_state_*\`) 의 JSON 상태 substrate 는 \`.leerness/\` (에이전트 간 인수인계 표준 — state 명령 사용 시 생성). 메인 워크스페이스(.harness)와 별개.\n- leerness 는 AGENTS.md 를 **대체하지 않고 보완**한다. 정적 지침은 여기, 동적 상태는 leerness.\n\n## Mandatory read order (session start)\n1. **.harness/session-workflow.md** (6단계 워크플로 — 최우선)\n2. .harness/context-routing.md\n3. .harness/session-handoff.md\n4. .harness/current-state.md\n5. .harness/plan.md\n6. .harness/progress-tracker.md\n7. .harness/guideline.md\n8. .harness/protected-files.md\n9. .harness/writeback-policy.md\n10. .harness/anti-lazy-work-policy.md\n11. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- **모호성 질문 의무**: 사용자 요청에 판단이 갈리는 부분(모호한 수식어/지시대명사/복수 선택지/불명확한 범위)이 있으면 **추측으로 구현하지 말고 먼저 사용자에게 질문**합니다. \`leerness clarify "<요청>"\` 이 감지한 질문 목록을 그대로 사용자에게 물어보세요. 신호가 없어도 스스로 판단이 갈리면 질문이 우선입니다.\n- **미리보기 승인 의무 (신규 기능)**: 사용자가 신규 기능 추가/구현을 요청하면 **코드를 먼저 작성하지 않습니다**. ① \`leerness preview add "<기능>" --design "<디자인/UX 설명>" --features "<기능 목록>"\` 으로 미리보기를 등록하고 ② 그 내용을 사용자에게 제시해 승인 또는 수정사항을 질문으로 받습니다. ③ 사용자가 승인하면 \`leerness preview approve <P-ID>\`, 수정 요구면 \`leerness preview revise <P-ID> --note "..."\` 후 미리보기를 고쳐 다시 제시합니다. **approve 전에는 해당 기능의 코드를 작성하지 않습니다.**
|
|
552
|
+
- **디자인 시안 의무 (웹페이지/디자인 작업)**: 신규 페이지 제작·디자인/리디자인 요청이면 텍스트 설명만으로 끝내지 않습니다. \`leerness preview mockup <P-ID>\` 로 자립형 HTML 시안 스캐폴드(\`.harness/previews/<P-ID>-mockup.html\`)를 만들고, **placeholder 영역을 실제 레이아웃 초안(HTML/CSS, 외부 리소스 없이)으로 교체**한 뒤 사용자에게 브라우저로 열어 보여주고 수정/승인을 질문으로 받습니다. 수정 요구가 오면 시안 파일을 고쳐 다시 제시하고, **approve 전에는 실제 페이지/기능 코드를 작성하지 않습니다.** (이미 만든 시안이 있으면 \`preview add ... --mockup <파일>\` 로 첨부)\n- 작업 분류는 \`leerness route <task-type>\`로 확인합니다 (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- 보호 파일/관리 섹션을 삭제하지 않습니다. 머지·아카이브·deprecated 표시를 사용합니다.\n- 의미 있는 변경 후 progress-tracker, current-state, task-log, session-handoff를 갱신합니다.\n- 완료 선언 전 \`leerness check .\` 또는 \`leerness lazy detect .\`로 자기검증하고, \`leerness lens\`의 분야별 자기질문에 답합니다 (코드: "선임 개발자가 복잡하다고 느끼지 않을까?" / 디자인: "선임 디자이너와 일반 사용자가 이쁘고 직관적이라 느낄까?").\n- 변경 전 secret/encoding 가드: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- 같은 기능 중복 생성 전 design-system.md, consistency-policy.md, reuse-map.md를 확인합니다.\n- 매 세션 종료 시 \`leerness session close .\`로 9개 카테고리(완료/진행중/미완료/예정/대기/보류/차단/드랍/검증) + **활성 룰 검증 결과**를 보고합니다.\n- 업데이트는 \`leerness update --check\` (감지) → \`leerness update --yes\` (자동 마이그레이션).\n\n## 자연어 회고/통찰/브레인스토밍\n사용자가 자연어로 회고/통찰/브레인스토밍을 요청하면 즉시 leerness 명령으로 호출합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "회고해줘 / 돌아보자 / 정리해줘" | \`leerness retro\` |\n| "최근 N일 회고" | \`leerness retro --days N\` |\n| "통계 / 누적 지표 / insights" | \`leerness insights\` |\n| "X에 대해 브레인스토밍 / X 관련 자료 / X 시작 전 검토" | \`leerness brainstorm "X"\` |\n\nsession close가 매번 자동으로 한 줄 요약을 출력하고, 5세션마다 자동 깊은 회고를 실행합니다. 사용자가 명시 요청 시 즉시 호출.\n\n## 자연어 룰 처리\n사용자가 자연어로 영구 룰을 요청하면 즉시 leerness rule 명령으로 등록합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "매 업데이트마다 버전 bump해줘" | \`leerness rule add "버전을 patch로 bump" --trigger every-update\` |\n| "매 커밋마다 패치노트 추가해줘" | \`leerness rule add "패치노트 추가" --trigger every-commit\` |\n| "세션 종료마다 배포해줘" | \`leerness rule add "배포 (release publish)" --trigger session-close\` |\n| "X 룰 중지/그만/끄기" | \`leerness rule pause <ID>\` (해당 룰 ID는 list로 확인) |\n| "X 룰 제거/삭제" | \`leerness rule remove <ID>\` |\n| "모든 룰 중지" | \`leerness rule stop\` |\n| "룰 다시 켜줘" | \`leerness rule resume-all\` 또는 \`leerness rule resume <ID>\` |\n\n룰을 등록한 후 사용자에게 등록 결과(ID + trigger + 설명)를 보고하고, 그 이후 매 세션마다 자동 적용합니다. 사용자가 "중지" 또는 "제거"를 명시적으로 말하기 전까지는 룰을 비활성화하지 않습니다.\n\n## 룰 자동 적용\nleerness가 자동 검증 가능한 trigger:\n- **every-update / version bump 키워드 룰**: package.json의 version이 갱신됐는지 검사 (handoff/session close가 baseline 캐시와 비교).\n- **CHANGELOG / 패치노트 키워드 룰**: CHANGELOG.md의 mtime이 갱신됐는지 검사.\n- **test / 테스트 / verify 키워드 룰**: review-evidence.md에 오늘 verify-code 흔적이 있는지 검사.\n- **배포 / publish / push 키워드 룰**: 자동 검증 불가 → 사용자에게 release publish 명령을 안내.\n\n자동 검증 가능한 룰의 실행은 \`leerness release bump\`, \`leerness release note "..."\`, \`leerness release publish\`를 사용해 자동화합니다.\n`,
|
|
552
553
|
'CLAUDE.md': `${MARK}\n# Claude Code Instructions\n\nFollow AGENTS.md. Always run \`leerness handoff .\` at the start and \`leerness session close .\` before ending a session.\n\n**⭐ 매 세션 첫 행동**: \`.harness/session-workflow.md\`의 6단계 워크플로(요청분석→계획→분배→sub-agent→종합검증→마감)를 따라야 함. drift critical 시 \`leerness drift check --auto-fix\`로 자동 회복.\n\nProtected files must not be deleted. Read .harness/anti-lazy-work-policy.md before claiming completion.\n\n## 자연어 영구 룰\n사용자가 "매 X마다 Y를 해줘" 같은 자연어 룰을 말하면 즉시 \`leerness rule add "Y" --trigger every-X\`로 등록하세요. 등록된 룰은 매 세션 \`handoff\`가 자동 출력하고, \`session close\`가 자동 검증해 보고합니다. 사용자가 "중지" / "그만" / "끄기"를 명시할 때만 \`rule pause/remove\`를 호출합니다.\n\n자세한 매핑은 AGENTS.md의 "자연어 룰 처리" 표를 참고하세요.\n`,
|
|
553
554
|
'.cursor/rules/leerness.mdc': `${MARK}\n---\nalwaysApply: true\n---\nFollow AGENTS.md and .harness/context-routing.md.\nRun: \`leerness handoff .\` at session start.\nRun: \`leerness session close .\` at session end.\nPreserve Leerness protected files.\n`,
|
|
554
555
|
'.github/copilot-instructions.md': `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .harness/ as project memory.\nDo not remove protected Leerness files.\nBefore completion, ensure plan.md, progress-tracker.md, current-state.md, session-handoff.md are updated.\n`,
|
|
@@ -768,7 +769,7 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
|
|
|
768
769
|
// 1.36.59 (외부감사 F-05 시리즈 1회차): en 프로젝트에서 에이전트가 "지시"로 읽는 최상위 5종은 완전 영어 —
|
|
769
770
|
// 실측: en init 산출물 61개 중 53개 한글 잔존, 그중 지시 레이어가 최고 영향. 나머지 표면은 후속 회차.
|
|
770
771
|
if (lang === 'en') {
|
|
771
|
-
_files['AGENTS.md'] = `${MARK}\n# Leerness Agent Instructions\n\n## ⭐ First action every session\n**Read \`.harness/session-workflow.md\` first and follow its 6-step workflow**: analyze request → plan → distribute → sub-agent work → integrated verification → close. Keep the same flow regardless of round length/complexity — that is what prevents drift.\n\n## Static vs dynamic — the leerness boundary\n**AGENTS.md = static project instructions** (coding rules, test commands, prohibitions, deploy steps — rarely change).\n**leerness = dynamic work state, memory, verification, handoff** (current goal, changed files, failed attempts, verification results, next-agent handoff — change every task).\n- Put rules/commands/prohibitions here in AGENTS.md.\n- Dynamic state (decisions/lessons/plan/progress/verification/handoff) is recorded by leerness in the **default workspace \`.harness/\`** (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md) via \`leerness handoff\`, \`decision add\`, \`lesson save\`, etc.\n- (Optional) the JSON state substrate of \`leerness state show|start|record|verify|handoff\` (or MCP \`leerness_state_*\`) lives in \`.leerness/\` — the cross-agent handoff standard, separate from the main workspace.\n- leerness **complements** AGENTS.md, it does not replace it. Static instructions here, dynamic state in leerness.\n\n## Mandatory read order (session start)\n1. **.harness/session-workflow.md** (6-step workflow — highest priority)\n2. .harness/context-routing.md\n3. .harness/session-handoff.md\n4. .harness/current-state.md\n5. .harness/plan.md\n6. .harness/progress-tracker.md\n7. .harness/guideline.md\n8. .harness/protected-files.md\n9. .harness/writeback-policy.md\n10. .harness/anti-lazy-work-policy.md\n11. **.harness/rules.md** (user-defined standing rules — follow every session)\n\n## Required behavior\n- Run \`leerness handoff .\` at session start to load context (handoff prints active rules automatically).\n- **Ask-on-ambiguity duty**: if the user's request has parts open to interpretation (vague qualifiers, pronouns, multiple alternatives, unclear scope), **ask the user instead of guessing**. Ask the questions produced by \`leerness clarify "<request>"\` verbatim. Even without detected signals, when your own judgment is split, asking wins.\n- **Preview-approval duty (new features)**: when the user asks for a new feature, **do not write code first**. ① Register a preview with \`leerness preview add "<feature>" --design "<design/UX>" --features "<list>"\`, ② present it and ask for approval or revisions, ③ on approval run \`leerness preview approve <P-ID>\`; on revision \`leerness preview revise <P-ID> --note "..."\` and re-present. **Never write the feature's code before approve.**\n- Classify work with \`leerness route <task-type>\` (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- Never delete protected files/managed sections — merge, archive, or mark deprecated instead.\n- After meaningful changes update progress-tracker, current-state, task-log, session-handoff.\n- Before claiming completion, self-verify with \`leerness check .\` or \`leerness lazy detect .\` and answer the \`leerness lens\` self-questions per domain (code: "would a senior developer find this needlessly complex?" / design: "would a senior designer and an ordinary user find this pretty and intuitive?").\n- Before changes run the guards: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- Before duplicating a capability check design-system.md, consistency-policy.md, reuse-map.md.\n- Close every session with \`leerness session close .\` — 9 categories (done/in-progress/incomplete/planned/waiting/on-hold/blocked/dropped/verification) + active-rule verification.\n- Updates: \`leerness update --check\` (detect) → \`leerness update --yes\` (auto-migrate).\n\n## Natural-language retro/insights/brainstorm\n| User phrase | Run immediately |\n|---|---|\n| "retrospective / look back / wrap up" | \`leerness retro\` |\n| "retro for last N days" | \`leerness retro --days N\` |\n| "stats / cumulative metrics / insights" | \`leerness insights\` |\n| "brainstorm about X / materials on X / review before starting X" | \`leerness brainstorm "X"\` |\n\nsession close prints a one-line summary automatically every time and runs a deep retrospective every 5 sessions; call the commands immediately when the user asks explicitly.\n\n## Natural-language standing rules\nWhen the user states a standing rule ("do Y every X"), register it immediately:\n\n| User phrase | Run immediately |\n|---|---|\n| "bump the version every update" | \`leerness rule add "bump version (patch)" --trigger every-update\` |\n| "add patch notes every commit" | \`leerness rule add "add patch notes" --trigger every-commit\` |\n| "deploy at session close" | \`leerness rule add "deploy (release publish)" --trigger session-close\` |\n| "pause/stop rule X" | \`leerness rule pause <ID>\` (find the ID with \`rule list\`) |\n| "remove rule X" | \`leerness rule remove <ID>\` |\n| "stop all rules" | \`leerness rule stop\` |\n| "resume rules" | \`leerness rule resume-all\` or \`leerness rule resume <ID>\` |\n\nAfter registering, report the result (ID + trigger + description) and apply it every session until the user explicitly says stop/remove.\n\n## Automatic rule verification\n- **every-update / version rules**: checks package.json version change.\n- **CHANGELOG / patch-note rules**: checks CHANGELOG.md mtime.\n- **test / verify rules**: checks today's verify traces in review-evidence.md.\n- **deploy / publish / push rules**: not auto-verifiable → guides \`leerness release publish\`.\n\nAutomate verifiable rules with \`leerness release bump\`, \`leerness release note "..."\`, and \`leerness release publish\`.\n`;
|
|
772
|
+
_files['AGENTS.md'] = `${MARK}\n# Leerness Agent Instructions\n\n## ⭐ First action every session\n**Read \`.harness/session-workflow.md\` first and follow its 6-step workflow**: analyze request → plan → distribute → sub-agent work → integrated verification → close. Keep the same flow regardless of round length/complexity — that is what prevents drift.\n\n## Static vs dynamic — the leerness boundary\n**AGENTS.md = static project instructions** (coding rules, test commands, prohibitions, deploy steps — rarely change).\n**leerness = dynamic work state, memory, verification, handoff** (current goal, changed files, failed attempts, verification results, next-agent handoff — change every task).\n- Put rules/commands/prohibitions here in AGENTS.md.\n- Dynamic state (decisions/lessons/plan/progress/verification/handoff) is recorded by leerness in the **default workspace \`.harness/\`** (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md) via \`leerness handoff\`, \`decision add\`, \`lesson save\`, etc.\n- (Optional) the JSON state substrate of \`leerness state show|start|record|verify|handoff\` (or MCP \`leerness_state_*\`) lives in \`.leerness/\` — the cross-agent handoff standard, separate from the main workspace.\n- leerness **complements** AGENTS.md, it does not replace it. Static instructions here, dynamic state in leerness.\n\n## Mandatory read order (session start)\n1. **.harness/session-workflow.md** (6-step workflow — highest priority)\n2. .harness/context-routing.md\n3. .harness/session-handoff.md\n4. .harness/current-state.md\n5. .harness/plan.md\n6. .harness/progress-tracker.md\n7. .harness/guideline.md\n8. .harness/protected-files.md\n9. .harness/writeback-policy.md\n10. .harness/anti-lazy-work-policy.md\n11. **.harness/rules.md** (user-defined standing rules — follow every session)\n\n## Required behavior\n- Run \`leerness handoff .\` at session start to load context (handoff prints active rules automatically).\n- **Ask-on-ambiguity duty**: if the user's request has parts open to interpretation (vague qualifiers, pronouns, multiple alternatives, unclear scope), **ask the user instead of guessing**. Ask the questions produced by \`leerness clarify "<request>"\` verbatim. Even without detected signals, when your own judgment is split, asking wins.\n- **Preview-approval duty (new features)**: when the user asks for a new feature, **do not write code first**. ① Register a preview with \`leerness preview add "<feature>" --design "<design/UX>" --features "<list>"\`, ② present it and ask for approval or revisions, ③ on approval run \`leerness preview approve <P-ID>\`; on revision \`leerness preview revise <P-ID> --note "..."\` and re-present. **Never write the feature's code before approve.**\n- **Design-mockup duty (web pages / design work)**: for a new page, redesign, or any visual design request, do not stop at a text description. Run \`leerness preview mockup <P-ID>\` to generate a self-contained HTML mockup scaffold (\`.harness/previews/<P-ID>-mockup.html\`), **replace the placeholder with a real layout draft (HTML/CSS, no external resources)**, show it to the user in a browser, and ask for revisions or approval. On revision requests, edit the mockup and re-present. **Never write the actual page/feature code before approve.** (If you already built a mockup, attach it with \`preview add ... --mockup <file>\`.)\n- Classify work with \`leerness route <task-type>\` (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- Never delete protected files/managed sections — merge, archive, or mark deprecated instead.\n- After meaningful changes update progress-tracker, current-state, task-log, session-handoff.\n- Before claiming completion, self-verify with \`leerness check .\` or \`leerness lazy detect .\` and answer the \`leerness lens\` self-questions per domain (code: "would a senior developer find this needlessly complex?" / design: "would a senior designer and an ordinary user find this pretty and intuitive?").\n- Before changes run the guards: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- Before duplicating a capability check design-system.md, consistency-policy.md, reuse-map.md.\n- Close every session with \`leerness session close .\` — 9 categories (done/in-progress/incomplete/planned/waiting/on-hold/blocked/dropped/verification) + active-rule verification.\n- Updates: \`leerness update --check\` (detect) → \`leerness update --yes\` (auto-migrate).\n\n## Natural-language retro/insights/brainstorm\n| User phrase | Run immediately |\n|---|---|\n| "retrospective / look back / wrap up" | \`leerness retro\` |\n| "retro for last N days" | \`leerness retro --days N\` |\n| "stats / cumulative metrics / insights" | \`leerness insights\` |\n| "brainstorm about X / materials on X / review before starting X" | \`leerness brainstorm "X"\` |\n\nsession close prints a one-line summary automatically every time and runs a deep retrospective every 5 sessions; call the commands immediately when the user asks explicitly.\n\n## Natural-language standing rules\nWhen the user states a standing rule ("do Y every X"), register it immediately:\n\n| User phrase | Run immediately |\n|---|---|\n| "bump the version every update" | \`leerness rule add "bump version (patch)" --trigger every-update\` |\n| "add patch notes every commit" | \`leerness rule add "add patch notes" --trigger every-commit\` |\n| "deploy at session close" | \`leerness rule add "deploy (release publish)" --trigger session-close\` |\n| "pause/stop rule X" | \`leerness rule pause <ID>\` (find the ID with \`rule list\`) |\n| "remove rule X" | \`leerness rule remove <ID>\` |\n| "stop all rules" | \`leerness rule stop\` |\n| "resume rules" | \`leerness rule resume-all\` or \`leerness rule resume <ID>\` |\n\nAfter registering, report the result (ID + trigger + description) and apply it every session until the user explicitly says stop/remove.\n\n## Automatic rule verification\n- **every-update / version rules**: checks package.json version change.\n- **CHANGELOG / patch-note rules**: checks CHANGELOG.md mtime.\n- **test / verify rules**: checks today's verify traces in review-evidence.md.\n- **deploy / publish / push rules**: not auto-verifiable → guides \`leerness release publish\`.\n\nAutomate verifiable rules with \`leerness release bump\`, \`leerness release note "..."\`, and \`leerness release publish\`.\n`;
|
|
772
773
|
_files['CLAUDE.md'] = `${MARK}\n# Claude Code Instructions\n\nFollow AGENTS.md. Always run \`leerness handoff .\` at the start and \`leerness session close .\` before ending a session.\n\n**⭐ First action every session**: follow the 6-step workflow in \`.harness/session-workflow.md\` (analyze → plan → distribute → sub-agent → verify → close). On drift critical, recover with \`leerness drift check --auto-fix\`.\n\nProtected files must not be deleted. Read .harness/anti-lazy-work-policy.md before claiming completion.\n\n## Natural-language standing rules\nWhen the user states "do Y every X", immediately run \`leerness rule add "Y" --trigger every-X\`. Registered rules are printed by every \`handoff\` and verified by \`session close\`. Only \`rule pause/remove\` when the user explicitly says stop/remove.\n\nSee the "Natural-language standing rules" table in AGENTS.md for the full mapping.\n`;
|
|
773
774
|
// 1.36.62 (F-05 4회차): commands/AX/잔여 참조층 en 완역
|
|
774
775
|
_files['.harness/test-evidence-policy.md'] = fm('test-evidence-policy', ['recording verification results'], ['verification format changes'], `# Test Evidence Policy\n\nEvery verification is appended to \`.harness/review-evidence.md\`.\n\n## Format\n\`\`\`\n## YYYY-MM-DD HH:MM\nTask: T-XXXX\nCommand: <command>\nExit: <code>\nNote: <key result summary>\nArtifacts: <screenshot/log paths>\n\`\`\`\n`, 'en');
|
|
@@ -6094,7 +6095,9 @@ function _loadAPISkill(root, id) {
|
|
|
6094
6095
|
// 1.12.5 (15th 버그헌트 P2, UR-0017): read()(BOM strip) + CRLF/CR 정규화 — 이전 raw readFileSync 는 CRLF 파일에서 '^---\n' 불일치로 frontmatter 전부 유실(1.9.408 SKILL.md 수정 누락분). 또 fallback 에 body 추가 → _matchAPISkills 의 s.body.slice 크래시 방지.
|
|
6095
6096
|
const content = read(fp).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
6096
6097
|
const fm = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
6097
|
-
|
|
6098
|
+
// 1.36.74 (9차 헌트 #6 P2): frontmatter 가 깨진 파일(닫는 --- 누락 등)이 "URL 0개짜리 정상 스킬"로 조용히 강등돼
|
|
6099
|
+
// 목록에 섞였다 — 손상 사실을 표시해 사용자가 고칠 수 있게(로드 자체는 유지, 비파괴).
|
|
6100
|
+
if (!fm) return { id, content, urls: [], name: id, body: content, corrupt: true, corruptReason: 'frontmatter_unparsable' };
|
|
6098
6101
|
const meta = {};
|
|
6099
6102
|
fm[1].split('\n').forEach(l => {
|
|
6100
6103
|
const m = l.match(/^(\w+):\s*(.*)$/);
|
|
@@ -6140,23 +6143,28 @@ async function apiSkillCmd(root, sub) {
|
|
|
6140
6143
|
const dir = _apiSkillsDir(root);
|
|
6141
6144
|
if (sub === 'list') {
|
|
6142
6145
|
if (!fs.existsSync(dir)) {
|
|
6143
|
-
if (has('--json')) { log(JSON.stringify({ skills: [] })); return; }
|
|
6146
|
+
if (has('--json')) { log(JSON.stringify({ skills: [], corruptCount: 0 })); return; } // (검수 Low) 빈 저장소도 동일 필드 형태
|
|
6144
6147
|
log(dm(`(아직 API skill 없음 — leerness api-skill add <url> 으로 생성)`));
|
|
6145
6148
|
return;
|
|
6146
6149
|
}
|
|
6147
6150
|
const skills = _listAPISkills(root);
|
|
6151
|
+
const _corrupt = skills.filter(s => s.corrupt); // 1.36.74 (#6): 손상 레코드를 정상처럼 보이게 두지 않는다
|
|
6148
6152
|
if (has('--json')) {
|
|
6149
|
-
log(JSON.stringify({
|
|
6153
|
+
log(JSON.stringify({
|
|
6154
|
+
skills: skills.map(s => ({ id: s.id, name: s.name, urls: s.urls, captured_at: s.captured_at, ...(s.corrupt ? { corrupt: true, corruptReason: s.corruptReason } : {}) })),
|
|
6155
|
+
corruptCount: _corrupt.length
|
|
6156
|
+
}, null, 2));
|
|
6150
6157
|
return;
|
|
6151
6158
|
}
|
|
6152
6159
|
log(cy(`# leerness api-skill list (1.9.245, UR-0015)`));
|
|
6153
6160
|
log('');
|
|
6154
6161
|
if (skills.length === 0) { log(dm(`(없음)`)); return; }
|
|
6155
6162
|
skills.forEach(s => {
|
|
6156
|
-
log(` • ${gr(s.id)} — ${s.name}`);
|
|
6163
|
+
log(` • ${gr(s.id)} — ${s.name}${s.corrupt ? yl(' ⚠ 손상(frontmatter 파싱 불가)') : ''}`);
|
|
6157
6164
|
if (s.urls && s.urls.length) log(dm(` ${s.urls[0]}${s.urls.length > 1 ? ` (+${s.urls.length - 1})` : ''}`));
|
|
6158
6165
|
if (s.direction) log(dm(` 방향: ${s.direction.slice(0, 80)}`));
|
|
6159
6166
|
});
|
|
6167
|
+
if (_corrupt.length) log(yl(` ⚠ 손상 ${_corrupt.length}건 — 해당 .md 의 frontmatter(--- 구분선)를 확인하세요`));
|
|
6160
6168
|
return;
|
|
6161
6169
|
}
|
|
6162
6170
|
// 1.9.245: 위치 인자 robust 추출 — argv 에서 cmd/sub 뒤 첫 non-flag (flag value 도 skip)
|
|
@@ -6177,9 +6185,9 @@ async function apiSkillCmd(root, sub) {
|
|
|
6177
6185
|
};
|
|
6178
6186
|
if (sub === 'show') {
|
|
6179
6187
|
const id = arg('--id') || _positionalAfterSub();
|
|
6180
|
-
if (!id) {
|
|
6188
|
+
if (!id) { failJson(has('--json'), 'missing_args', 'id 필요: leerness api-skill show <id>'); return; } // 1.36.74: --json 에러 구조화
|
|
6181
6189
|
const s = _loadAPISkill(root, id);
|
|
6182
|
-
if (!s) {
|
|
6190
|
+
if (!s) { failJson(has('--json'), 'not_found', `api-skill 없음: ${id}`); return; }
|
|
6183
6191
|
if (has('--json')) { log(JSON.stringify(s, null, 2)); return; }
|
|
6184
6192
|
log(fs.readFileSync(path.join(dir, id + '.md'), 'utf8'));
|
|
6185
6193
|
return;
|
|
@@ -6198,23 +6206,28 @@ async function apiSkillCmd(root, sub) {
|
|
|
6198
6206
|
}
|
|
6199
6207
|
if (sub === 'drop') {
|
|
6200
6208
|
const id = arg('--id') || _positionalAfterSub();
|
|
6201
|
-
if (!id) {
|
|
6209
|
+
if (!id) { failJson(has('--json'), 'missing_args', 'id 필요: leerness api-skill drop <id>'); return; }
|
|
6202
6210
|
const fp = path.join(dir, id + '.md');
|
|
6203
|
-
if (!fs.existsSync(fp)) {
|
|
6211
|
+
if (!fs.existsSync(fp)) { failJson(has('--json'), 'not_found', `없음: ${id}`); return; } // 1.36.74: --json 에러 구조화
|
|
6204
6212
|
fs.unlinkSync(fp);
|
|
6213
|
+
if (has('--json')) { log(JSON.stringify({ ok: true, dropped: id }, null, 2)); return; }
|
|
6205
6214
|
log(gr(`✓ 삭제: ${id}`));
|
|
6206
6215
|
return;
|
|
6207
6216
|
}
|
|
6208
6217
|
if (sub === 'add') {
|
|
6218
|
+
// 1.36.74 (9차 헌트 자체관측): --json 인데 사람용 진행 로그/실패 메시지가 stdout 을 오염시켜 JSON 소비자(MCP/CI)가
|
|
6219
|
+
// 파싱 불가 — 다른 표면에서 이미 고친 --json 계약 위반의 잔존 지점. 진행 로그 억제 + 실패도 구조화.
|
|
6220
|
+
const _j = has('--json');
|
|
6221
|
+
const _hlog = (s) => { if (!_j) log(s); };
|
|
6209
6222
|
const url = arg('--url') || _positionalAfterSub();
|
|
6210
|
-
if (!url) {
|
|
6223
|
+
if (!url) { failJson(_j, 'missing_args', 'URL 필요: leerness api-skill add <url> [--direction "..."]'); return; }
|
|
6211
6224
|
const direction = arg('--direction', '');
|
|
6212
6225
|
const name = arg('--name', '');
|
|
6213
6226
|
const noCrawl = has('--no-crawl');
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
if (direction)
|
|
6217
|
-
|
|
6227
|
+
_hlog(cy(`# leerness api-skill add (1.9.245, UR-0015)`));
|
|
6228
|
+
_hlog(` URL: ${url}`);
|
|
6229
|
+
if (direction) _hlog(` 방향: ${direction}`);
|
|
6230
|
+
_hlog(dm(` Fetch + crawl (depth=1, same-domain, max 10)...`));
|
|
6218
6231
|
try {
|
|
6219
6232
|
const doc = await _collectAPIDoc(url, { direction, noCrawl });
|
|
6220
6233
|
const skillName = name || doc.title || new URL(url).hostname;
|
|
@@ -6231,17 +6244,27 @@ async function apiSkillCmd(root, sub) {
|
|
|
6231
6244
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
6232
6245
|
const md = _serializeAPISkill(id, skillName, [url], direction, doc);
|
|
6233
6246
|
fs.writeFileSync(path.join(dir, id + '.md'), md, 'utf8');
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
if (
|
|
6247
|
+
_hlog(gr(`✓ 저장: .harness/api-skills/${id}.md`));
|
|
6248
|
+
_hlog(dm(` title: ${doc.title || '(none)'}`));
|
|
6249
|
+
_hlog(dm(` related links: ${(doc.related_links || []).length}`));
|
|
6250
|
+
_hlog(dm(` text size: ${doc.text.length} chars`));
|
|
6251
|
+
if (_j) log(JSON.stringify({ ok: true, id, name: skillName, url, related_count: (doc.related_links || []).length }, null, 2));
|
|
6239
6252
|
} catch (e) {
|
|
6240
|
-
|
|
6253
|
+
_hlog(rd(`✗ Fetch 실패: ${e.message}`));
|
|
6241
6254
|
// 1.9.245: --skeleton fallback — Cloudflare/WAF 차단된 URL 도 빈 .md 골격 생성 → 사용자가 수동 채움
|
|
6242
6255
|
if (has('--skeleton') || /403|401|429/.test(e.message)) {
|
|
6243
|
-
|
|
6244
|
-
|
|
6256
|
+
// 1.36.74 (9차 헌트 #5 P1): skeleton 폴백에 충돌 해소기가 없어 URL 이 다른데 slug 가 같으면 앞 항목을
|
|
6257
|
+
// 통째로 덮어썼다(실측: alpha URL·direction 소실). fetch 경로(1.36.28 #4)와 동일 규칙 공유.
|
|
6258
|
+
let skillName, id;
|
|
6259
|
+
try { skillName = name || new URL(url).hostname; } catch { skillName = name || String(url).slice(0, 60); }
|
|
6260
|
+
id = _apiSkillId(url, name);
|
|
6261
|
+
const _idFileS = (i) => path.join(dir, i + '.md');
|
|
6262
|
+
if (fs.existsSync(_idFileS(id))) {
|
|
6263
|
+
const existing = fs.readFileSync(_idFileS(id), 'utf8');
|
|
6264
|
+
const um = existing.match(/^urls:\s*\r?\n\s*-\s*(\S.*)$/m);
|
|
6265
|
+
const storedUrl = um ? um[1].trim() : '';
|
|
6266
|
+
if (storedUrl && storedUrl !== url) id = id + '-' + _shortHash(url);
|
|
6267
|
+
}
|
|
6245
6268
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
6246
6269
|
const stub = {
|
|
6247
6270
|
url, title: skillName, text: `(fetch 실패 — 사용자가 직접 ${url} 에서 내용을 복사해 채워주세요)`,
|
|
@@ -6249,11 +6272,12 @@ async function apiSkillCmd(root, sub) {
|
|
|
6249
6272
|
};
|
|
6250
6273
|
const md = _serializeAPISkill(id, skillName, [url], direction, stub);
|
|
6251
6274
|
fs.writeFileSync(path.join(dir, id + '.md'), md, 'utf8');
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
if (
|
|
6275
|
+
_hlog(yl(`⚠ 빈 골격 생성 (--skeleton fallback): .harness/api-skills/${id}.md`));
|
|
6276
|
+
_hlog(dm(` → 직접 ${url} 내용을 복사해서 .md 파일에 채워넣어주세요`));
|
|
6277
|
+
_hlog(dm(` → 이후 leerness handoff 가 매칭 시 참조됩니다`));
|
|
6278
|
+
if (_j) log(JSON.stringify({ ok: true, id, skeleton: true, url, name: skillName, fetchError: e.message }, null, 2));
|
|
6256
6279
|
} else {
|
|
6280
|
+
if (_j) log(JSON.stringify({ ok: false, code: 'fetch_failed', error: e.message, url }, null, 2)); // 실패도 구조화(종전: --json 에 사람용 텍스트만)
|
|
6257
6281
|
process.exitCode = 1;
|
|
6258
6282
|
}
|
|
6259
6283
|
}
|
|
@@ -6716,7 +6740,7 @@ function commandsCmd(root) {
|
|
|
6716
6740
|
{ cmd: 'clarify "<사용자 요청>" [--json]', desc: '요청 모호성 신호 감지 → 사용자에게 물을 질문 생성 (추측 구현 방지) — 1.36.51 UR-0061' },
|
|
6717
6741
|
{ cmd: 'tech [--json]', desc: '기술 프로필 — 개발 언어·연결 서비스 자동 감지 + 마이그레이션/언어전환 이력, 그래프 🛠 탭 표시 — 1.36.53 UR-0062' },
|
|
6718
6742
|
{ cmd: 'integrity check [--repair] [--json]', desc: 'managed 정책-문서 12종 무결성(부재/H1 상실/절단) 점검 — --repair: archive 대피 후 템플릿 재생성 — 1.36.57 감사 F-04' },
|
|
6719
|
-
{ cmd: 'preview add|list|show|approve|revise', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지
|
|
6743
|
+
{ cmd: 'preview add|list|show|approve|revise|mockup', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--force]: 자립형 HTML 디자인 시안 스캐폴드 · add --mockup <파일>: 기존 시안 첨부 — 1.36.51 UR-0061 · 1.36.75 UR-0066' },
|
|
6720
6744
|
{ cmd: 'review <file> --persona <ids>', desc: '페르소나 리뷰 (1.9.29)' },
|
|
6721
6745
|
{ cmd: 'brainstorm "<topic>" [--include-code]', desc: '워크스페이스 회수 + 코드 grep' }
|
|
6722
6746
|
],
|
|
@@ -9744,6 +9768,39 @@ function memoryRestoreCmd(root, surface, target) {
|
|
|
9744
9768
|
const activePath = surface === 'decisions' ? decisionsPath(root)
|
|
9745
9769
|
: surface === 'lessons' ? lessonsPath(root)
|
|
9746
9770
|
: planPath(root);
|
|
9771
|
+
// 1.36.74 (9차 헌트 #4 P1): 같은 항목이 이미 active 에 있으면 복원이 조용히 중복 레코드를 만들었다
|
|
9772
|
+
// (drop → 같은 제목 재생성 → restore = 동일 제목 2건, 어느 쪽이 진짜인지 불명). 충돌은 기본 거부하고
|
|
9773
|
+
// --force 로만 진행 — 복원은 "되살리기"지 "복제"가 아니다. archive 는 손대지 않으므로 재시도 가능.
|
|
9774
|
+
if (!has('--force')) {
|
|
9775
|
+
// 1.36.74 (검수 High-2 + Medium): 첫 줄 substring 비교는 (a) 날짜만 다른 논리적 중복을 놓치고
|
|
9776
|
+
// (b) 같은 날 저장된 무관한 lesson·'Deploy' vs 'Deploy v2' 를 오탐 차단했다(양쪽 실측).
|
|
9777
|
+
// 표면별 "정체성"(decision=title, lesson=text, plan=제목)을 파싱해 정확 일치로만 판정.
|
|
9778
|
+
const _activeNow = exists(activePath) ? read(activePath) : '';
|
|
9779
|
+
const _identities = (mdText) => {
|
|
9780
|
+
try {
|
|
9781
|
+
if (surface === 'decisions') return new Set((_decisionsFromMd(mdText) || []).map(d => String(d.title || '').trim()).filter(Boolean));
|
|
9782
|
+
if (surface === 'lessons') return new Set((_parseLessonEntries(mdText) || []).map(l => String(l.text || '').trim()).filter(Boolean));
|
|
9783
|
+
} catch {}
|
|
9784
|
+
// plan: '## M-XXXX 제목' 헤딩의 제목부 (M-ID 는 재발급될 수 있어 제목으로 판정)
|
|
9785
|
+
const out = new Set();
|
|
9786
|
+
for (const l of String(mdText).split('\n')) {
|
|
9787
|
+
const m = l.match(/^##+\s*(?:M-\d+\s*)?(.+?)\s*$/);
|
|
9788
|
+
if (m && m[1] && !/^제거 /.test(m[1])) out.add(m[1].trim());
|
|
9789
|
+
}
|
|
9790
|
+
return out;
|
|
9791
|
+
};
|
|
9792
|
+
const _activeIds = _identities(_activeNow);
|
|
9793
|
+
const _dupes = [];
|
|
9794
|
+
for (const blk of restoredBlocks) {
|
|
9795
|
+
for (const id of _identities(blk)) if (_activeIds.has(id)) _dupes.push(id.slice(0, 70));
|
|
9796
|
+
}
|
|
9797
|
+
if (_dupes.length) {
|
|
9798
|
+
failJson(has('--json'), 'restore_conflict',
|
|
9799
|
+
`이미 active 에 동일 항목 존재 — 중복 생성 방지로 복원 거부 (${_dupes.length}건: ${_dupes.slice(0, 2).join(' · ')}). ` +
|
|
9800
|
+
`archive 는 그대로 두었으니 active 쪽을 정리 후 재시도하거나, 의도한 중복이면 --force`);
|
|
9801
|
+
return;
|
|
9802
|
+
}
|
|
9803
|
+
}
|
|
9747
9804
|
// active 파일에 복귀 (헤더 보존)
|
|
9748
9805
|
for (const blk of restoredBlocks) {
|
|
9749
9806
|
append(activePath, '\n' + blk + '\n');
|
|
@@ -19057,6 +19114,17 @@ function mcpServeCmd(root) {
|
|
|
19057
19114
|
|
|
19058
19115
|
// stdin JSON-RPC 한 줄 단위
|
|
19059
19116
|
let buf = '';
|
|
19117
|
+
// 1.36.74 (9차 헌트 #7): 한 줄 처리 로직을 함수로 — EOF 시 개행 없이 남은 마지막 요청이 조용히 유실되던 것
|
|
19118
|
+
// (실측: 개행 없는 ping 은 무응답 exit 0, 개행 있는 동일 요청은 정상 응답). 클라이언트가 flush 를 개행 없이 끝내면 요청 손실.
|
|
19119
|
+
const _handleLine = (line) => {
|
|
19120
|
+
if (!line) return;
|
|
19121
|
+
// 1.36.39 (#3): 파싱과 핸들링 분리 — 핸들러 내부 예외가 -32700 "Parse error"(id null)로 오보되지 않게.
|
|
19122
|
+
let req = null;
|
|
19123
|
+
try { req = JSON.parse(line); }
|
|
19124
|
+
catch (e) { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: ' + e.message } }); return; }
|
|
19125
|
+
try { handleRequest(req); }
|
|
19126
|
+
catch (e) { send({ jsonrpc: '2.0', id: (req && req.id != null) ? req.id : null, error: { code: -32603, message: 'Internal error: ' + e.message } }); }
|
|
19127
|
+
};
|
|
19060
19128
|
process.stdin.setEncoding('utf8');
|
|
19061
19129
|
process.stdin.on('data', chunk => {
|
|
19062
19130
|
buf += chunk;
|
|
@@ -19064,16 +19132,15 @@ function mcpServeCmd(root) {
|
|
|
19064
19132
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
19065
19133
|
const line = buf.slice(0, nl).trim();
|
|
19066
19134
|
buf = buf.slice(nl + 1);
|
|
19067
|
-
|
|
19068
|
-
// 1.36.39 (#3): 파싱과 핸들링 분리 — 핸들러 내부 예외가 -32700 "Parse error"(id null)로 오보되지 않게.
|
|
19069
|
-
let req = null;
|
|
19070
|
-
try { req = JSON.parse(line); }
|
|
19071
|
-
catch (e) { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: ' + e.message } }); continue; }
|
|
19072
|
-
try { handleRequest(req); }
|
|
19073
|
-
catch (e) { send({ jsonrpc: '2.0', id: (req && req.id != null) ? req.id : null, error: { code: -32603, message: 'Internal error: ' + e.message } }); }
|
|
19135
|
+
_handleLine(line);
|
|
19074
19136
|
}
|
|
19075
19137
|
});
|
|
19076
|
-
process.stdin.on('end', () =>
|
|
19138
|
+
process.stdin.on('end', () => {
|
|
19139
|
+
const rest = buf.trim();
|
|
19140
|
+
buf = '';
|
|
19141
|
+
if (rest) _handleLine(rest); // 개행 없이 끝난 마지막 요청도 처리
|
|
19142
|
+
process.exit(0);
|
|
19143
|
+
});
|
|
19077
19144
|
// 인터럽트 처리
|
|
19078
19145
|
process.on('SIGINT', () => process.exit(0));
|
|
19079
19146
|
process.on('SIGTERM', () => process.exit(0));
|
|
@@ -20248,7 +20315,32 @@ function _loadLeernessState(root) {
|
|
|
20248
20315
|
const f = path.join(_leernessStateDir(root), 'state.json');
|
|
20249
20316
|
if (!exists(f)) return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
|
|
20250
20317
|
// 1.36.28 (#3): 손상 파일을 빈 상태로 오인하면 카운터가 0으로 리셋돼 run-0001 을 덮어쓴다(데이터 유실). 파싱 실패는 던진다.
|
|
20251
|
-
|
|
20318
|
+
let _st;
|
|
20319
|
+
try { _st = JSON.parse(read(f)); } catch { throw Object.assign(new Error(`.leerness/state.json 손상 — 덮어쓰기 거부: ${f} (복구/삭제 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f }); }
|
|
20320
|
+
// 1.36.74 (9차 헌트 #1 P1): "파싱은 되지만 스키마 무효"(예: `{}`)가 위 가드를 통과해 runCounter 를 0 으로 되돌리고
|
|
20321
|
+
// 기존 run-0001.json 을 덮어쓰던 것(실측: original-goal → replacement-goal). 형태 검증도 fail-closed.
|
|
20322
|
+
// 단, runs 디렉토리가 비어 있으면(진짜 신규/빈 상태) 관대하게 기본값 — 없는 데이터를 지킬 필요는 없다.
|
|
20323
|
+
const _shapeOk = _st && typeof _st === 'object' && !Array.isArray(_st)
|
|
20324
|
+
&& Number.isInteger(_st.runCounter) && _st.runCounter >= 0
|
|
20325
|
+
&& (_st.currentRunId === null || typeof _st.currentRunId === 'string');
|
|
20326
|
+
// 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
|
|
20327
|
+
let _maxRun = 0;
|
|
20328
|
+
try {
|
|
20329
|
+
for (const x of fs.readdirSync(path.join(_leernessStateDir(root), 'runs'))) {
|
|
20330
|
+
const m = /^run-(\d+)\.json$/.exec(x);
|
|
20331
|
+
if (m) _maxRun = Math.max(_maxRun, parseInt(m[1], 10));
|
|
20332
|
+
}
|
|
20333
|
+
} catch {}
|
|
20334
|
+
if (!_shapeOk) {
|
|
20335
|
+
if (_maxRun > 0) throw Object.assign(new Error(`.leerness/state.json 스키마 무효 — 덮어쓰기 거부: ${f} (runs/ 에 기존 기록 있음 · 복구/삭제 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f });
|
|
20336
|
+
return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
|
|
20337
|
+
}
|
|
20338
|
+
// 1.36.74 (검수 High-1): 형태는 유효한데 값이 stale 한 카운터(runCounter < 실재 최대 run)도 다음 start 가
|
|
20339
|
+
// 기존 run 파일을 덮어쓴다(실측 ORIGINAL→REPLACEMENT). 형태 검증만으론 부족 — 디스크 실재와 대조해 fail-closed.
|
|
20340
|
+
if (_st.runCounter < _maxRun) {
|
|
20341
|
+
throw Object.assign(new Error(`.leerness/state.json 카운터 불일치 — 덮어쓰기 거부: runCounter=${_st.runCounter} < 실재 run-${String(_maxRun).padStart(4, '0')} (${f} 복구 또는 runCounter 를 ${_maxRun} 이상으로 수정 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f });
|
|
20342
|
+
}
|
|
20343
|
+
return _st;
|
|
20252
20344
|
}
|
|
20253
20345
|
function _saveLeernessState(root, state) {
|
|
20254
20346
|
const dir = _leernessStateDir(root); mkdirp(dir);
|
|
@@ -23171,7 +23263,7 @@ async function main() {
|
|
|
23171
23263
|
// 미지 플래그(--design/--features/--note)의 값이 positional 로 새는 것 차단 — 원시 argv 에서 플래그+값 스킵 (1.36.49 release note 패턴)
|
|
23172
23264
|
const _pvRaw = process.argv.slice(2); const _pvI = _pvRaw.indexOf('preview'); const _pvToks = [];
|
|
23173
23265
|
for (let i = _pvI + 1; i < _pvRaw.length && _pvI >= 0; i++) {
|
|
23174
|
-
if (_pvRaw[i].startsWith('--')) { if (['--design', '--features', '--note', '--path'].includes(_pvRaw[i]) && _pvRaw[i + 1]) i++; continue; }
|
|
23266
|
+
if (_pvRaw[i].startsWith('--')) { if (['--design', '--features', '--note', '--path', '--mockup'].includes(_pvRaw[i]) && _pvRaw[i + 1]) i++; continue; } // 1.36.75 (검수 #2): --mockup 값이 제목에 흡수되던 것
|
|
23175
23267
|
_pvToks.push(_pvRaw[i]);
|
|
23176
23268
|
}
|
|
23177
23269
|
return _clar.previewCmd(arg('--path', process.cwd()), _pvToks[0], _pvToks.slice(1), { has, arg, _withLock }); // 1.36.54 (#2): 변경은 락 직렬화
|
package/lib/clarify.js
CHANGED
|
@@ -82,6 +82,62 @@ function _nextPreviewId(list) {
|
|
|
82
82
|
|
|
83
83
|
function pendingPreviews(root) { return _loadPreviews(root).filter(p => p.status === 'proposed' || p.status === 'revision-requested'); }
|
|
84
84
|
|
|
85
|
+
// 1.36.75 (UR-0066): 디자인/페이지 작업 감지 — "신규 페이지·디자인 요청은 코드 전에 HTML 시안 먼저" 리마인더용.
|
|
86
|
+
// false-PASS 편향: 명시적 신호어가 있을 때만 (일반 기능 요청까지 시안 강제하지 않는다).
|
|
87
|
+
const DESIGN_WORK_RE = /(페이지\s*(제작|추가|만들|생성)|새\s*페이지|신규\s*페이지|디자인\s*(작업|해|변경|개편|시안)|리디자인|랜딩\s*페이지|화면\s*(설계|디자인)|UI\s*(작업|개편|디자인)|redesign|landing page|new page|design (?:a |the )?(?:page|screen|ui))/i;
|
|
88
|
+
function _isDesignWork(text) { return DESIGN_WORK_RE.test(String(text || '')); }
|
|
89
|
+
|
|
90
|
+
// HTML 이스케이프 (시안 스캐폴드에 사용자 텍스트 임베드용)
|
|
91
|
+
function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
92
|
+
|
|
93
|
+
// 자립형 HTML 시안 스캐폴드 — AI 가 섹션을 채워 사용자에게 브라우저로 제시. 0-deps·오프라인(외부 리소스 없음).
|
|
94
|
+
function _mockupScaffold(p) {
|
|
95
|
+
const feats = (p.features || []).map(f => ` <li>${_esc(f)}</li>`).join('\n');
|
|
96
|
+
return `<!doctype html>
|
|
97
|
+
<html lang="ko">
|
|
98
|
+
<head>
|
|
99
|
+
<meta charset="utf-8">
|
|
100
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
101
|
+
<title>${_esc(p.id)} 시안 — ${_esc(p.title)}</title>
|
|
102
|
+
<style>
|
|
103
|
+
:root { --ink:#1a1a1a; --sub:#666; --line:#e5e5e5; --accent:#2563eb; --bg:#fafafa; }
|
|
104
|
+
* { box-sizing:border-box; margin:0; }
|
|
105
|
+
body { font-family:system-ui,-apple-system,'Segoe UI',sans-serif; color:var(--ink); background:var(--bg); }
|
|
106
|
+
.meta { background:#fff; border-bottom:2px solid var(--accent); padding:14px 24px; font-size:13px; color:var(--sub); }
|
|
107
|
+
.meta b { color:var(--ink); }
|
|
108
|
+
.meta .status { float:right; }
|
|
109
|
+
main { max-width:960px; margin:32px auto; padding:0 24px; }
|
|
110
|
+
section.mock { background:#fff; border:1px dashed var(--line); border-radius:10px; padding:28px; margin-bottom:20px; }
|
|
111
|
+
section.mock h2 { font-size:15px; color:var(--accent); margin-bottom:12px; }
|
|
112
|
+
.placeholder { border:2px dashed #cbd5e1; border-radius:8px; padding:40px; text-align:center; color:#94a3b8; font-size:14px; }
|
|
113
|
+
.notes { font-size:13px; color:var(--sub); line-height:1.7; }
|
|
114
|
+
.notes li { margin-left:18px; }
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
<div class="meta"><b>${_esc(p.id)}</b> 「${_esc(p.title)}」 — 디자인 시안 <span class="status">status: ${_esc(p.status)} · ${_esc(p.createdAt || '')}</span></div>
|
|
119
|
+
<main>
|
|
120
|
+
<section class="mock">
|
|
121
|
+
<h2>디자인 방향</h2>
|
|
122
|
+
<p class="notes">${_esc(p.design || '(preview add --design 으로 방향을 기록하세요)')}</p>
|
|
123
|
+
</section>
|
|
124
|
+
<section class="mock">
|
|
125
|
+
<h2>포함 기능</h2>
|
|
126
|
+
<ul class="notes">
|
|
127
|
+
${feats || ' <li>(없음 — --features "a,b" 로 기록)</li>'}
|
|
128
|
+
</ul>
|
|
129
|
+
</section>
|
|
130
|
+
<section class="mock">
|
|
131
|
+
<h2>화면 시안 — ⬇ AI 는 이 영역을 실제 레이아웃 초안으로 교체하세요</h2>
|
|
132
|
+
<div class="placeholder">여기에 헤더 / 히어로 / 본문 / 푸터 등 실제 화면 구조의 HTML/CSS 초안을 그립니다.<br>외부 리소스 없이(오프라인) 이 파일 하나로 열리게 유지하세요.</div>
|
|
133
|
+
</section>
|
|
134
|
+
</main>
|
|
135
|
+
<!-- leerness:mockup ${_esc(p.id)} — 이 파일은 시안입니다. 승인(leerness preview approve ${_esc(p.id)}) 전 실제 코드 작성 금지. -->
|
|
136
|
+
</body>
|
|
137
|
+
</html>
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
85
141
|
// `leerness preview add "<제목>" [--design "..."] [--features "a,b"] | list | show <id> | approve <id> | revise <id> --note "..."`
|
|
86
142
|
function previewCmd(root, sub, rest, deps = {}) {
|
|
87
143
|
const { has, arg } = deps;
|
|
@@ -90,7 +146,7 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
90
146
|
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
|
|
91
147
|
sub = sub || 'list';
|
|
92
148
|
// 1.36.54 (#2 High): 변경 하위명령은 락 안에서 재로드→검증→저장 전체를 직렬화 — 동시 add 의 lost-update 차단.
|
|
93
|
-
const _mutating = ['add', 'approve', 'revise'].includes(sub);
|
|
149
|
+
const _mutating = ['add', 'approve', 'revise', 'mockup'].includes(sub); // 1.36.75: mockup 도 스토어에 경로 기록
|
|
94
150
|
if (_mutating && deps._withLock && !deps._locked) {
|
|
95
151
|
return deps._withLock(_previewsPath(root), () => previewCmd(root, sub, rest, Object.assign({}, deps, { _locked: true })));
|
|
96
152
|
}
|
|
@@ -106,10 +162,25 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
106
162
|
const design = arg ? (arg('--design', '') || '') : '';
|
|
107
163
|
const features = (arg ? (arg('--features', '') || '') : '').split(',').map(s => s.trim()).filter(Boolean);
|
|
108
164
|
const p = { id: _nextPreviewId(list), title, design, features, status: 'proposed', createdAt: now(), history: [{ at: now(), event: 'proposed' }] };
|
|
165
|
+
// 1.36.75 (UR-0066): --mockup <파일> — AI 가 만든 시안 HTML 을 등록 시점에 첨부 (존재 검증)
|
|
166
|
+
const mk = arg ? (arg('--mockup', '') || '') : '';
|
|
167
|
+
if (mk) {
|
|
168
|
+
const mkAbs = path.resolve(path.isAbsolute(mk) ? mk : path.join(root, mk));
|
|
169
|
+
// (검수 #3 Medium) 존재만 보던 검증 강화: 일반 파일이어야 하고(디렉토리 거부), 프로젝트 루트 안이어야 한다
|
|
170
|
+
// — 시안은 프로젝트 산출물이라 루트 밖 경로는 이식성이 깨진다.
|
|
171
|
+
let _isFile = false;
|
|
172
|
+
try { _isFile = require('fs').statSync(mkAbs).isFile(); } catch {}
|
|
173
|
+
if (!_isFile) { failJson(json, 'mockup_not_found', `--mockup 파일 없음(또는 디렉토리): ${mkAbs} — 먼저 시안 파일을 만들거나, 등록 후 leerness preview mockup <P-ID> 로 스캐폴드 생성`); return; }
|
|
174
|
+
const _rel = path.relative(root, mkAbs);
|
|
175
|
+
if (_rel.startsWith('..') || path.isAbsolute(_rel)) { failJson(json, 'mockup_outside_root', `--mockup 은 프로젝트 루트 안의 파일이어야 합니다: ${mkAbs} (root: ${root})`); return; }
|
|
176
|
+
p.mockupPath = _rel.replace(/\\/g, '/');
|
|
177
|
+
}
|
|
109
178
|
list.push(p);
|
|
110
179
|
if (!_savePreviews(root, list, json)) return;
|
|
111
180
|
if (json) { log(JSON.stringify({ ok: true, ...p }, null, 2)); return; }
|
|
112
181
|
ok(`preview 등록: ${p.id} 「${title}」 (status: proposed)`);
|
|
182
|
+
if (p.mockupPath) log(` 🎨 시안: ${p.mockupPath} — 사용자가 브라우저로 열어 확인`);
|
|
183
|
+
else if (_isDesignWork(title + ' ' + design)) log(` 🎨 디자인/페이지 작업 감지 — leerness preview mockup ${p.id} 로 HTML 시안 스캐폴드를 만들어 채운 뒤 제시하세요.`);
|
|
113
184
|
log(` → 사용자에게 이 미리보기(디자인/기능)를 제시하고 승인/수정 답을 받으세요.`);
|
|
114
185
|
log(` → 승인: leerness preview approve ${p.id} · 수정요구: leerness preview revise ${p.id} --note "..."`);
|
|
115
186
|
log(` ⓘ 계약: approve 전에는 이 기능의 코드를 작성하지 않는다.`);
|
|
@@ -122,7 +193,7 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
122
193
|
if (!list.length) { log(' (없음) — 신규 기능 착수 전: leerness preview add "<제목>" --design "..." --features "a,b"'); return; }
|
|
123
194
|
for (const p of list) {
|
|
124
195
|
const icon = p.status === 'approved' ? '✅' : (p.status === 'revision-requested' ? '📝' : '⏳');
|
|
125
|
-
log(` ${icon} ${p.id} ${p.title} [${p.status}]`);
|
|
196
|
+
log(` ${icon} ${p.id} ${p.title} [${p.status}]${p.mockupPath ? ' 🎨 ' + p.mockupPath : ''}`);
|
|
126
197
|
}
|
|
127
198
|
const pend = pendingPreviews(root).length;
|
|
128
199
|
if (pend) warn(` 미승인 ${pend}건 — 사용자 답을 받기 전 해당 기능 코드 작성 금지`);
|
|
@@ -131,14 +202,49 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
131
202
|
|
|
132
203
|
const id = (rest || [])[0];
|
|
133
204
|
const p = list.find(x => x.id === id);
|
|
134
|
-
if (sub === 'show' || sub === 'approve' || sub === 'revise') {
|
|
205
|
+
if (sub === 'show' || sub === 'approve' || sub === 'revise' || sub === 'mockup') {
|
|
135
206
|
if (!id) { failJson(json, 'id_required', `preview ${sub} <P-ID> 필요 (leerness preview list 로 확인)`); return; }
|
|
136
207
|
if (!p) { failJson(json, 'not_found', `preview 없음: ${id}`); return; }
|
|
137
208
|
}
|
|
138
209
|
|
|
210
|
+
// 1.36.75 (UR-0066): `preview mockup <P-ID> [--force]` — 자립형 HTML 시안 스캐폴드 생성 + 스토어에 경로 기록.
|
|
211
|
+
// AI 워크플로: 스캐폴드 생성 → placeholder 를 실제 레이아웃 초안으로 교체 → 사용자에게 브라우저로 제시 → approve/revise.
|
|
212
|
+
if (sub === 'mockup') {
|
|
213
|
+
// (검수 High) 스토어의 id 는 신뢰 입력이 아니다 — 조작된 id(`..\..\owned`)가 경로에 끼어들면 루트 밖 쓰기.
|
|
214
|
+
// 정식 형식(P-\d{4,}) 강제 + 산출 경로의 .harness/previews 내부 확인 이중 가드.
|
|
215
|
+
if (!/^P-\d{4,}$/.test(p.id)) { failJson(json, 'invalid_id', `preview id 형식 무효: ${JSON.stringify(p.id)} — previews.json 수동 복구 필요 (정식: P-0001)`); return; }
|
|
216
|
+
const mkDir = path.join(root, '.harness', 'previews');
|
|
217
|
+
const mkFile = path.resolve(mkDir, `${p.id}-mockup.html`);
|
|
218
|
+
if (path.relative(mkDir, mkFile).startsWith('..')) { failJson(json, 'invalid_id', `시안 경로가 previews 디렉토리를 벗어남: ${mkFile}`); return; }
|
|
219
|
+
// (검수 #5 Medium) 승인 후 시안 생성/재생성은 승인 상태를 낡게 만든다 — revise 로 되돌린 뒤에만 허용
|
|
220
|
+
if (p.status === 'approved') { failJson(json, 'already_approved', `${p.id} 는 이미 승인됨 — 시안을 바꾸려면 먼저 leerness preview revise ${p.id} --note "..." 로 수정 상태로 되돌리세요`); return; }
|
|
221
|
+
const _regen = exists(mkFile);
|
|
222
|
+
if (_regen && !(has && has('--force'))) {
|
|
223
|
+
// 이미 있으면 덮어쓰지 않는다 — AI 가 채워 넣은 시안 보호 (재생성은 --force)
|
|
224
|
+
// (검수 #4 Medium) 보호하더라도 스토어에 경로는 기록 — list/show 에서 시안이 안 보이던 것
|
|
225
|
+
const relPath = path.relative(root, mkFile).replace(/\\/g, '/');
|
|
226
|
+
if (p.mockupPath !== relPath) { p.mockupPath = relPath; if (!_savePreviews(root, list, json)) return; }
|
|
227
|
+
if (json) { log(JSON.stringify({ ok: true, id: p.id, mockupPath: relPath, created: false, note: 'exists — use --force to regenerate' }, null, 2)); return; }
|
|
228
|
+
ok(`시안 이미 존재: ${path.relative(root, mkFile)} — 내용 보호를 위해 덮어쓰지 않음 (재생성: --force) · 경로는 기록됨`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
writeUtf8(mkFile, _mockupScaffold(p));
|
|
232
|
+
p.mockupPath = path.relative(root, mkFile).replace(/\\/g, '/');
|
|
233
|
+
// (검수 Low) 재생성은 별도 이벤트로 — 이력 오해 방지
|
|
234
|
+
(p.history = p.history || []).push({ at: now(), event: _regen ? 'mockup-regenerated' : 'mockup-created' });
|
|
235
|
+
if (!_savePreviews(root, list, json)) return;
|
|
236
|
+
if (json) { log(JSON.stringify({ ok: true, id: p.id, mockupPath: p.mockupPath, created: !_regen, regenerated: _regen }, null, 2)); return; }
|
|
237
|
+
ok(`시안 ${_regen ? '재생성' : '스캐폴드 생성'}: ${p.mockupPath}`);
|
|
238
|
+
log(` → AI: placeholder 영역을 실제 레이아웃 초안(HTML/CSS)으로 교체하세요 (외부 리소스 없이).`);
|
|
239
|
+
log(` → 사용자에게 이 파일을 브라우저로 열어 보여주고 승인/수정을 질문으로 받으세요.`);
|
|
240
|
+
log(` ⓘ 계약: approve 전에는 실제 기능 코드를 작성하지 않는다.`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
139
244
|
if (sub === 'show') {
|
|
140
245
|
if (json) { log(JSON.stringify({ ok: true, ...p }, null, 2)); return; }
|
|
141
246
|
log(`# ${p.id} 「${p.title}」 [${p.status}]`);
|
|
247
|
+
if (p.mockupPath) log(` 🎨 시안: ${p.mockupPath}`);
|
|
142
248
|
if (p.design) log(` 디자인: ${p.design}`);
|
|
143
249
|
if (p.features && p.features.length) { log(' 기능:'); p.features.forEach(f => log(` - ${f}`)); }
|
|
144
250
|
(p.history || []).forEach(h => log(` · ${h.at} ${h.event}${h.note ? ` — ${h.note}` : ''}`));
|
|
@@ -166,7 +272,7 @@ function previewCmd(root, sub, rest, deps = {}) {
|
|
|
166
272
|
ok(`${p.id} 수정요구 기록${note ? ` — ${note}` : ''} — 미리보기를 수정해 다시 제시하세요 (코드 작성은 계속 보류).`);
|
|
167
273
|
return;
|
|
168
274
|
}
|
|
169
|
-
failJson(json, 'unknown_subcommand', `알 수 없는 preview 하위명령: ${sub} (가능: add, list, show, approve, revise)`);
|
|
275
|
+
failJson(json, 'unknown_subcommand', `알 수 없는 preview 하위명령: ${sub} (가능: add, list, show, approve, revise, mockup)`);
|
|
170
276
|
}
|
|
171
277
|
|
|
172
|
-
module.exports = { CLARIFY_SIGNALS, _clarifySignals, clarifyCmd, previewCmd, pendingPreviews, _previewsPath, _loadPreviews, _loadPreviewsChecked };
|
|
278
|
+
module.exports = { CLARIFY_SIGNALS, _clarifySignals, clarifyCmd, previewCmd, pendingPreviews, _previewsPath, _loadPreviews, _loadPreviewsChecked, _isDesignWork, DESIGN_WORK_RE, _mockupScaffold };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.75",
|
|
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
|
@@ -7337,5 +7337,137 @@ total++;
|
|
|
7337
7337
|
if (!ok) failed++;
|
|
7338
7338
|
}
|
|
7339
7339
|
|
|
7340
|
+
// 1.36.74 (9차 헌트): state 스키마 fail-closed · restore 중복 거부 · api-skill skeleton 충돌 · MCP 마지막 줄 · 손상 표시 · --json 계약
|
|
7341
|
+
total++;
|
|
7342
|
+
{
|
|
7343
|
+
let ok = false;
|
|
7344
|
+
const _d = [];
|
|
7345
|
+
try {
|
|
7346
|
+
// #1 파싱되지만 스키마 무효인 state.json → 기존 run 보호
|
|
7347
|
+
const d1 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9s-')); _d.push(d1);
|
|
7348
|
+
cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'original-goal', '--path', d1, '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
7349
|
+
const runFile = path.join(d1, '.leerness', 'runs', 'run-0001.json');
|
|
7350
|
+
const before = fs.readFileSync(runFile, 'utf8');
|
|
7351
|
+
fs.writeFileSync(path.join(d1, '.leerness', 'state.json'), '{}\n');
|
|
7352
|
+
const s2 = cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'replacement-goal', '--path', d1, '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
7353
|
+
const stateOk = s2.status === 1 && fs.readFileSync(runFile, 'utf8') === before;
|
|
7354
|
+
// 신규(빈 runs) 프로젝트에서는 관대 — 무효 state 가 있어도 정상 시작
|
|
7355
|
+
const d1b = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9s2-')); _d.push(d1b);
|
|
7356
|
+
fs.mkdirSync(path.join(d1b, '.leerness'), { recursive: true });
|
|
7357
|
+
fs.writeFileSync(path.join(d1b, '.leerness', 'state.json'), '{}\n');
|
|
7358
|
+
const freshOk = cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'g', '--path', d1b, '--json'], { encoding: 'utf8', timeout: 30000 }).status === 0;
|
|
7359
|
+
// #4 restore 충돌 거부 + 정상 복원 무회귀 + --force 우회
|
|
7360
|
+
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9r-')); _d.push(d2);
|
|
7361
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes', '--minimal', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
|
|
7362
|
+
const D = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d2], { encoding: 'utf8', timeout: 20000 });
|
|
7363
|
+
D(['decision', 'add', 'Collision Decision', '--reason', 'archived-original']);
|
|
7364
|
+
D(['decision', 'drop', 'Collision Decision']);
|
|
7365
|
+
D(['decision', 'add', 'Collision Decision', '--reason', 'active-replacement']);
|
|
7366
|
+
const conflict = D(['memory', 'restore', 'decisions', 'Collision Decision']);
|
|
7367
|
+
const cnt = () => { try { return (JSON.parse(D(['decision', 'list', '--json']).stdout).decisions || []).filter(x => x.title === 'Collision Decision').length; } catch { return -1; } };
|
|
7368
|
+
const restoreOk = conflict.status === 1 && cnt() === 1 && D(['memory', 'restore', 'decisions', 'Collision Decision', '--force']).status === 0 && cnt() === 2;
|
|
7369
|
+
// (검수 High-1) 형태는 유효하나 stale 한 runCounter 도 기존 run 을 덮어쓰면 안 된다
|
|
7370
|
+
const d1c = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9st-')); _d.push(d1c);
|
|
7371
|
+
cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'ORIGINAL', '--path', d1c, '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
7372
|
+
const rf1 = path.join(d1c, '.leerness', 'runs', 'run-0001.json');
|
|
7373
|
+
const beforeStale = fs.readFileSync(rf1, 'utf8');
|
|
7374
|
+
const stp = path.join(d1c, '.leerness', 'state.json');
|
|
7375
|
+
const stj = JSON.parse(fs.readFileSync(stp, 'utf8')); stj.runCounter = 0; stj.currentRunId = null;
|
|
7376
|
+
fs.writeFileSync(stp, JSON.stringify(stj, null, 2));
|
|
7377
|
+
const staleR = cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'REPLACEMENT', '--path', d1c, '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
7378
|
+
const staleOk = staleR.status === 1 && fs.readFileSync(rf1, 'utf8') === beforeStale;
|
|
7379
|
+
// (검수 High-2) 날짜만 다른 논리적 중복도 차단 · (검수 Medium) 같은 날 무관한 항목은 오탐 없이 복원
|
|
7380
|
+
const d2b = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9id-')); _d.push(d2b);
|
|
7381
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d2b, '--yes', '--minimal', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
|
|
7382
|
+
const D2 = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d2b], { encoding: 'utf8', timeout: 20000 });
|
|
7383
|
+
D2(['decision', 'add', 'Dup Title', '--reason', 'first']); D2(['decision', 'drop', 'Dup Title']);
|
|
7384
|
+
const ap2 = path.join(d2b, '.harness', 'decisions.archive.md');
|
|
7385
|
+
if (fs.existsSync(ap2)) fs.writeFileSync(ap2, fs.readFileSync(ap2, 'utf8').replace(/### \d{4}-\d{2}-\d{2}/g, '### 2020-01-01'));
|
|
7386
|
+
D2(['decision', 'add', 'Dup Title', '--reason', 'second']);
|
|
7387
|
+
const logicalOk = D2(['memory', 'restore', 'decisions', 'Dup Title']).status === 1;
|
|
7388
|
+
D2(['lesson', 'save', 'AAA 무관한 교훈']); D2(['lesson', 'save', 'BBB 다른 교훈']); D2(['lesson', 'drop', 'AAA 무관한 교훈']);
|
|
7389
|
+
const noFpOk = D2(['memory', 'restore', 'lessons', 'AAA']).status === 0; // 같은 날 저장이어도 정당한 복원은 통과
|
|
7390
|
+
// #5 skeleton 경로 id 충돌 → 양쪽 보존
|
|
7391
|
+
const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9a-')); _d.push(d3);
|
|
7392
|
+
const A = (u, dir) => cp.spawnSync(process.execPath, [CLI, 'api-skill', 'add', u, '--skeleton', '--no-crawl', '--direction', dir, '--path', d3, '--json'], { encoding: 'utf8', timeout: 25000 });
|
|
7393
|
+
A('ftp://example.test/docs?alpha', 'alpha-direction'); A('ftp://example.test/docs?beta', 'beta-direction');
|
|
7394
|
+
const askDir = path.join(d3, '.harness', 'api-skills');
|
|
7395
|
+
const all = fs.existsSync(askDir) ? fs.readdirSync(askDir).map(f => fs.readFileSync(path.join(askDir, f), 'utf8')).join('\n') : '';
|
|
7396
|
+
const apiOk = /alpha-direction/.test(all) && /beta-direction/.test(all);
|
|
7397
|
+
// #7 개행 없는 마지막 MCP 요청도 응답
|
|
7398
|
+
const noNl = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 25000, input: JSON.stringify({ jsonrpc: '2.0', id: 101, method: 'ping' }) });
|
|
7399
|
+
let mcpOk = false;
|
|
7400
|
+
try { mcpOk = JSON.parse((noNl.stdout || '').trim()).id === 101; } catch {}
|
|
7401
|
+
// #6 손상 frontmatter 표시 + api-skill --json 계약(사람용 텍스트 오염 0)
|
|
7402
|
+
const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9c-')); _d.push(d4);
|
|
7403
|
+
fs.mkdirSync(path.join(d4, '.harness', 'api-skills'), { recursive: true });
|
|
7404
|
+
fs.writeFileSync(path.join(d4, '.harness', 'api-skills', 'bad.md'), '---\nid: intended\nname: X\n# no closing');
|
|
7405
|
+
let corruptOk = false, jsonOk = false;
|
|
7406
|
+
try { const j = JSON.parse(cp.spawnSync(process.execPath, [CLI, 'api-skill', 'list', '--path', d4, '--json'], { encoding: 'utf8', timeout: 20000 }).stdout); corruptOk = j.corruptCount === 1 && j.skills[0].corrupt === true; } catch {}
|
|
7407
|
+
try { const j = JSON.parse(cp.spawnSync(process.execPath, [CLI, 'api-skill', 'add', 'not-a-url', '--path', d4, '--json'], { encoding: 'utf8', timeout: 25000 }).stdout); jsonOk = j.ok === false && j.code === 'fetch_failed'; } catch {}
|
|
7408
|
+
ok = stateOk && freshOk && restoreOk && staleOk && logicalOk && noFpOk && apiOk && mcpOk && corruptOk && jsonOk;
|
|
7409
|
+
if (!ok) console.log(` [h9 디버그] state=${stateOk} fresh=${freshOk} restore=${restoreOk} stale=${staleOk} logical=${logicalOk} noFp=${noFpOk} api=${apiOk} mcp=${mcpOk} corrupt=${corruptOk} json=${jsonOk}`);
|
|
7410
|
+
} catch (e) {} finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
7411
|
+
console.log(ok ? '✓ B(1.36.74) 9차헌트: state 스키마 fail-closed · restore 충돌거부(--force 우회) · skeleton 충돌보존 · MCP 마지막줄 · 손상표시 · --json 계약' : '✗ 9차 헌트 수정 실패');
|
|
7412
|
+
if (!ok) failed++;
|
|
7413
|
+
}
|
|
7414
|
+
|
|
7415
|
+
// 1.36.75 (UR-0066): 디자인 시안 워크플로 — preview mockup 스캐폴드 + 덮어쓰기 보호 + XSS 이스케이프 + 디자인 감지 + --mockup 첨부
|
|
7416
|
+
total++;
|
|
7417
|
+
{
|
|
7418
|
+
let ok = false;
|
|
7419
|
+
const _d = [];
|
|
7420
|
+
try {
|
|
7421
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ur66-')); _d.push(d);
|
|
7422
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
|
|
7423
|
+
const P = (a) => cp.spawnSync(process.execPath, [CLI, 'preview', ...a, '--path', d], { encoding: 'utf8', timeout: 20000 });
|
|
7424
|
+
// 디자인 작업 감지 힌트
|
|
7425
|
+
const add = P(['add', '신규 랜딩 페이지 제작', '--design', '히어로+3단', '--features', '히어로,FAQ']);
|
|
7426
|
+
const detectOk = add.status === 0 && /preview mockup P-0001/.test(add.stdout);
|
|
7427
|
+
// 스캐폴드 생성 + 스토어 기록
|
|
7428
|
+
const mk = P(['mockup', 'P-0001']);
|
|
7429
|
+
const mkFile = path.join(d, '.harness', 'previews', 'P-0001-mockup.html');
|
|
7430
|
+
const html = fs.readFileSync(mkFile, 'utf8');
|
|
7431
|
+
const mkOk = mk.status === 0 && html.includes('신규 랜딩 페이지 제작') && html.includes('leerness:mockup P-0001');
|
|
7432
|
+
let showOk = false;
|
|
7433
|
+
try { showOk = JSON.parse(P(['show', 'P-0001', '--json']).stdout).mockupPath === '.harness/previews/P-0001-mockup.html'; } catch {}
|
|
7434
|
+
// 재실행은 AI 가 채운 시안을 보호 (--force 로만 재생성)
|
|
7435
|
+
fs.appendFileSync(mkFile, '<!-- AI-FILLED -->');
|
|
7436
|
+
const protectOk = P(['mockup', 'P-0001']).status === 0 && fs.readFileSync(mkFile, 'utf8').includes('AI-FILLED')
|
|
7437
|
+
&& !(P(['mockup', 'P-0001', '--force']).status !== 0) && !fs.readFileSync(mkFile, 'utf8').includes('AI-FILLED');
|
|
7438
|
+
// XSS: 제목의 스크립트가 시안에 이스케이프
|
|
7439
|
+
P(['add', '<script>alert(1)</script> 페이지', '--design', 'x']);
|
|
7440
|
+
P(['mockup', 'P-0002']);
|
|
7441
|
+
const xssOk = !fs.readFileSync(path.join(d, '.harness', 'previews', 'P-0002-mockup.html'), 'utf8').includes('<script>alert');
|
|
7442
|
+
// --mockup 첨부 (존재 검증 포함)
|
|
7443
|
+
fs.writeFileSync(path.join(d, 'my-mockup.html'), '<h1>시안</h1>');
|
|
7444
|
+
let attachOk = false;
|
|
7445
|
+
// (검수 #2) --mockup 값이 제목에 흡수되지 않아야
|
|
7446
|
+
try { const j = JSON.parse(P(['add', '첨부형 페이지', '--mockup', 'my-mockup.html', '--json']).stdout); attachOk = j.mockupPath === 'my-mockup.html' && j.title === '첨부형 페이지'; } catch {}
|
|
7447
|
+
const missOk = P(['add', '없는 첨부', '--mockup', 'no-such.html']).status === 1;
|
|
7448
|
+
// (검수 #3) 디렉토리·루트 밖 첨부 거부
|
|
7449
|
+
fs.mkdirSync(path.join(d, 'adir'), { recursive: true });
|
|
7450
|
+
const dirOk = P(['add', 'd-dir', '--mockup', 'adir']).status === 1;
|
|
7451
|
+
const outFile = path.join(path.dirname(d), `ur66-out-${path.basename(d)}.html`);
|
|
7452
|
+
fs.writeFileSync(outFile, '<h1>o</h1>'); _d.push(outFile.replace(/\.html$/, '')); // rm 은 파일에도 동작(force)
|
|
7453
|
+
const outOk = P(['add', 'd-out', '--mockup', outFile]).status === 1;
|
|
7454
|
+
try { fs.rmSync(outFile, { force: true }); } catch {}
|
|
7455
|
+
// (검수 High) 조작된 id 는 previews 디렉토리 밖 쓰기 불가
|
|
7456
|
+
const evil = path.join(d, '.harness', 'previews.json');
|
|
7457
|
+
const saved = fs.readFileSync(evil, 'utf8');
|
|
7458
|
+
fs.writeFileSync(evil, JSON.stringify([{ id: '..\\..\\owned', title: 'x', status: 'proposed' }]));
|
|
7459
|
+
const evilOk = cp.spawnSync(process.execPath, [CLI, 'preview', 'mockup', '..\\..\\owned', '--path', d], { encoding: 'utf8', timeout: 20000 }).status === 1
|
|
7460
|
+
&& !fs.existsSync(path.join(d, '..', 'owned-mockup.html'));
|
|
7461
|
+
fs.writeFileSync(evil, saved);
|
|
7462
|
+
// (검수 #5) 승인 후 mockup 거부
|
|
7463
|
+
P(['approve', 'P-0001']);
|
|
7464
|
+
const apprOk = P(['mockup', 'P-0001', '--force']).status === 1;
|
|
7465
|
+
ok = detectOk && mkOk && showOk && protectOk && xssOk && attachOk && missOk && dirOk && outOk && evilOk && apprOk;
|
|
7466
|
+
if (!ok) console.log(` [ur66 디버그] detect=${detectOk} mk=${mkOk} show=${showOk} protect=${protectOk} xss=${xssOk} attach=${attachOk} miss=${missOk} dir=${dirOk} out=${outOk} evil=${evilOk} appr=${apprOk}`);
|
|
7467
|
+
} catch (e) {} finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
7468
|
+
console.log(ok ? '✓ B(1.36.75) UR-0066: preview mockup 시안(스캐폴드·보호·XSS·감지힌트·--mockup 첨부)' : '✗ UR-0066 시안 워크플로 실패');
|
|
7469
|
+
if (!ok) failed++;
|
|
7470
|
+
}
|
|
7471
|
+
|
|
7340
7472
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
7341
7473
|
if (failed > 0) process.exit(1);
|