leerness 1.36.62 → 1.36.64

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.64 — 2026-07-22 — lint 게이트 (외부감사 F-10) — R-0001 검수 9회전
4
+
5
+ 과거 실사고(raw U+2028 정규식 리터럴 → 전 명령 SyntaxError 출하) 재발을 구조적으로 차단하는 0-deps lint.
6
+
7
+ - **`scripts/lint.js` + `npm run lint`**: 배포 js 전수 node --check 구문 · U+2028/U+2029 감지 · BOM · JSON 유효성 (~3초). `npm test`/`test:fast`/(검수 #1)`test:core` 전 티어 선두 배선.
8
+ - 구현 중 자기참조 트랩 재발(6번째)을 자체 검출로 확인 — 검사 문자를 \u 이스케이프 표기로.
9
+ - (검수 #2) selftest 를 4축 전부 행위 검증으로(구문/U+2028/BOM/JSON 각각 검출 + 클린 통과 + 전 티어 배선). (검수 #3) 스폰 실패를 구문오류와 구분 + SyntaxError 본문 라인 우선 표시.
10
+ - 검수 통과 프로브: 실 28파일 클린 · FP 0(\u 이스케이프 텍스트/250KB 라인/CRLF) · 패키지 동봉·설치본 루트 해석 정상.
11
+
12
+ ## 1.36.63 — 2026-07-22 — F-05 시리즈 완결: README 섹션·헤더·런타임 기본값 en (5회차) — R-0001 검수 8회전
13
+
14
+ **en init 한글 잔존 53/61 → 6/61** — 잔여 6개 전부 스킬팩 한국어 콘텐츠(데이터, 의도적 제외). 감사 F-05 종결.
15
+
16
+ - **README 관리 섹션 en**(정체성/5계층/명령/CRUD/MCP/빠른시작/planning + 검수 #3 보강: list 예시·release/autonomous 절 + `--next-action`→`--next` 오기 정정 — KO 블록도 동일 오기였음). syncReadme 가 프로젝트 언어 감지.
17
+ - **rules/progress 헤더 en**: 파서(readRules/readProgressRows)가 라인앵커+위치 기반임을 확인 후 frontmatter/라벨만 en. rule add 시 en 헤더 유지(writeRules 언어 해석), (검수 #1) 손상 헤더 복구도 프로젝트 언어로.
18
+ - **런타임 기본값 언어 인지**: 시드 task·task/plan add 기본 nextAction(`write the next action`)·(검수 #2) plan Done-When `(unset)`·task/plan drop 사유 — lazy detect 가 en 기본값도 trivial 로 인식(감지 동수 유지).
19
+ - (검수 #4) _mergeReadmeSection 말단 개행 정규화 — 재init 바이트 멱등.
20
+ - 검증: en 파서 전 명령 무회귀(검수: lazy/drift/audit KO=EN 동수), ko 무회귀, selftest 332, 게이트.
21
+
3
22
  ## 1.36.62 — 2026-07-21 — commands/AX/잔여 참조층 en 완역 (F-05 4회차) — R-0001 검수 7회전
4
23
 
5
24
  en init 한글 잔존 23/61 → **9/61**. 잔여 9개는 성격이 다른 대상(스킬 카탈로그 한국어 데이터 5·파서-결합 헤더 2·README 관리 섹션·skill-index 데이터 컬럼) — 별도 판단 대상으로 명시.
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.62 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.64 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -164,7 +164,7 @@ leerness decision list --query "키워드" # 1.9.139
164
164
  leerness rule add "매 commit마다 changelog 갱신" --trigger every-commit
165
165
  leerness rule list
166
166
  # Plan (milestones)
167
- leerness plan add "M-XXXX 계획" --next-action "다음 단계"
167
+ leerness plan add "M-XXXX 계획" --next "다음 단계"
168
168
  leerness plan list
169
169
  # Lessons (영구 교훈)
170
170
  leerness lesson save "교훈 본문" --tag perf
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.62는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
+ Leerness v1.36.64는 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.62는 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.62 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.64 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,6 +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.62: 2026-07-22
238
+ Last synced by Leerness v1.36.64: 2026-07-22
239
239
  <!-- leerness:project-readme:end -->
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.62';
37
+ const VERSION = '1.36.64';
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') 시 호스트 프로세스 오염.
@@ -305,7 +305,93 @@ function ask(question) {
305
305
  }
306
306
 
307
307
  // 1.9.140: managedReadmeBlock 자동 풍부화 — 매 release pack 마다 명령/MCP/성능/가이드 갱신
308
- function managedReadmeBlock(project) {
308
+ function managedReadmeBlock(project, lang = 'ko') {
309
+ // 1.36.63 (F-05 5회차): en 프로젝트 README 관리 섹션 완전 영어 — 계약 동등(정체성/5계층/명령/CRUD/MCP/빠른시작/planning files)
310
+ if (lang === 'en') {
311
+ return [
312
+ README_START,
313
+ '## Leerness Project Harness',
314
+ '',
315
+ `This project uses the Leerness v${VERSION} harness. AI agents must load context with \`leerness handoff\` before working, and run \`leerness check\` / \`leerness audit\` / \`leerness session close\` afterwards.`,
316
+ '',
317
+ '### Identity — an operating layer for AI agents',
318
+ '',
319
+ 'Leerness is **not an executor or coding agent** — it is a **universal operating layer** that sits on top of any AI coding agent (Claude Code · Codex · Cursor · Goose, …). It provides five shared layers:',
320
+ '',
321
+ '- **Memory** — persists project state/decisions/progress into `.harness/`',
322
+ '- **Policy** — 8-tier permission levels + enforce (read-only→publish), MCP call gate',
323
+ '- **Handoff** — standard context transfer between agents + one-call onboarding via `get_project_context`',
324
+ '- **Verification** — evidence-based completion checks that catch false "done" claims (advisory; blocking when required via the CI gate)',
325
+ '- **Audit** — automatic drift/idempotency/secret/encoding audits (self-heal: drift·idempotency --auto-fix, encoding --apply; secrets are detection-only)',
326
+ '',
327
+ 'It **complements AGENTS.md** (static instructions), never replaces it — static rules live in AGENTS.md; dynamic state, verification, and handoff live in leerness. Identity lookup: `leerness about` (MCP `leerness_about`).',
328
+ '',
329
+ '### Core Commands',
330
+ '',
331
+ '```bash',
332
+ 'leerness handoff . # auto-load session context',
333
+ 'leerness status . # install status',
334
+ 'leerness verify . # required-file verification',
335
+ 'leerness audit . # consistency / plan-progress audit',
336
+ 'leerness scan secrets . # secret pattern scan',
337
+ 'leerness encoding check . # UTF-8 / BOM / NUL / .bat encoding',
338
+ 'leerness lazy detect . # lazy-work (false-done) evaluation',
339
+ 'leerness memory search "key" # search decisions/history',
340
+ 'leerness session close . # close session + auto handoff',
341
+ 'leerness update . # auto version detect + migrate',
342
+ '```',
343
+ '',
344
+ '### Memory Surface CRUD (5 surfaces × add/list/drop)',
345
+ '',
346
+ '```bash',
347
+ 'leerness task add "task title"',
348
+ 'leerness task list --json',
349
+ 'leerness decision add "decision title" --reason "why"',
350
+ 'leerness decision list --query "keyword"',
351
+ 'leerness rule add "update the changelog every commit" --trigger every-commit',
352
+ 'leerness rule list',
353
+ 'leerness plan add "M-XXXX plan" --next "next step"',
354
+ 'leerness plan list',
355
+ 'leerness lesson save "lesson body" --tag perf',
356
+ 'leerness lesson list --query "keyword"',
357
+ 'leerness memory archive list . --query "keyword"',
358
+ 'leerness memory restore decisions <date|title>',
359
+ '```',
360
+ '',
361
+ '### Release automation & autonomous mode',
362
+ '',
363
+ '```bash',
364
+ 'leerness release pack --close --auto-main-push # one-shot release automation (incl. main push)',
365
+ '```',
366
+ '',
367
+ 'Send the `<<autonomous-loop-dynamic>>` signal and the AI will: pick the next round → change code → update regression tests → pass the full e2e suite → npm publish + git tag → main push → session close → schedule the next round.',
368
+ '',
369
+ '### MCP server (external AI integration)',
370
+ '',
371
+ `Leerness v${VERSION} ships a stdio JSON-RPC MCP server — exposing **${_mcpToolCount()} tools** to Claude Code · Cursor · Codex CLI and others (\`leerness mcp serve\`; \`--profile core\` = 20 essentials).`,
372
+ '',
373
+ '### Quick start',
374
+ '',
375
+ '```bash',
376
+ 'npm install -g leerness',
377
+ 'cd my-project && leerness init . --yes --skills recommended',
378
+ 'leerness handoff . # at every session start',
379
+ 'leerness session close . # at every session end',
380
+ '```',
381
+ '',
382
+ '### Planning Files',
383
+ '',
384
+ '- `.harness/plan.md`: overall goal, milestones, dropped scope',
385
+ '- `.harness/progress-tracker.md`: per-request status and evidence',
386
+ '- `.harness/current-state.md`: what to continue right now',
387
+ '- `.harness/session-handoff.md`: next-session handoff (auto-written)',
388
+ '- `.harness/lessons.md` / `decisions.md` / `rules.md`: permanent memory (5 surfaces)',
389
+ '',
390
+ `Last synced by Leerness v${VERSION}: ${today()}`,
391
+ README_END,
392
+ ''
393
+ ].join('\n');
394
+ }
309
395
  return [
310
396
  README_START,
311
397
  '## Leerness Project Harness',
@@ -352,7 +438,7 @@ function managedReadmeBlock(project) {
352
438
  'leerness rule add "매 commit마다 changelog 갱신" --trigger every-commit',
353
439
  'leerness rule list',
354
440
  '# Plan (milestones)',
355
- 'leerness plan add "M-XXXX 계획" --next-action "다음 단계"',
441
+ 'leerness plan add "M-XXXX 계획" --next "다음 단계"',
356
442
  'leerness plan list',
357
443
  '# Lessons (영구 교훈)',
358
444
  'leerness lesson save "교훈 본문" --tag perf',
@@ -456,7 +542,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = [], opts = {}) {
456
542
  '.harness/skills-lock.json': skillLock(selectedSkills),
457
543
  '.harness/project-brief.md': fm('project-brief', ['프로젝트 목적 확인','신규 기능 판단','계획 수립'], ['프로젝트 목적 변경','사용자/범위 변경'], `# Project Brief\n\n## Project\n${project}\n\n## Purpose\n- 이 프로젝트의 목적을 실제 내용으로 업데이트하세요.\n\n## Users\n-\n\n## Success Criteria\n-\n`),
458
544
  '.harness/plan.md': fm('plan', ['작업 시작 전','새 요청 접수','범위 변경','신규 프로젝트 감지'], ['계획 추가/수정/드랍','milestone 변경','목표 변경'], `# Plan\n\n## Goal\n- 사용자 목적을 기준으로 전체 계획을 유지합니다.\n\n## Scope\n- 포함 범위를 기록합니다.\n\n## Out of Scope / Dropped\n| ID | Item | Reason | Date |\n|---|---|---|---|\n\n## Milestones\n\n### M-0001. 프로젝트 계획 정리\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] project-brief.md를 실제 프로젝트 목적에 맞게 작성\n- [ ] context-map.md를 실제 파일 구조에 맞게 작성\n`),
459
- '.harness/progress-tracker.md': _canonicalProgressHeader() + '\n', // 1.9.293: _canonicalProgressHeader 단일 출처 (progressHeader 폴백과 동일)
545
+ '.harness/progress-tracker.md': _canonicalProgressHeader(lang) + '\n', // 1.9.293: _canonicalProgressHeader 단일 출처 · 1.36.63: lang 전달
460
546
  '.harness/guideline.md': fm('guideline', ['구현 전 품질 기준 확인','계획 이행 기준 확인'], ['개발 기준 변경','검증 루틴 변경'], `# Guideline\n\n## Operating Principle\n- plan.md의 목표와 범위를 기준으로 작업합니다.\n- progress-tracker.md의 요청 상태를 기준으로 완료/미완료를 판단합니다.\n- guideline.md에는 진행률 수치를 직접 기록하지 않습니다. 진행률은 plan.md/progress-tracker.md가 단일 출처입니다.\n\n## Quality Gate\n- 변경 전 관련 route를 확인합니다 (\`leerness route <task-type>\`).\n- 변경 후 \`leerness verify\`, \`leerness audit\`, \`leerness check\`을 실행합니다.\n- 완료 선언 전 \`leerness lazy detect\`을 실행합니다.\n- 세션 종료 시 \`leerness session close\`를 실행합니다.\n`),
461
547
  '.harness/plan-progress-boundary.md': fm('plan-progress-boundary', ['계획과 진행률이 중복될 때','작업 추적 구조 변경'], ['역할 분리 기준 변경'], `# Plan / Progress Boundary\n\n## plan.md\n- 전체 목표, milestone, 포함/제외 범위, 계획 변경 이력.\n\n## progress-tracker.md\n- 사용자 요청 단위의 상태, 증거, 다음 액션.\n- ID 규칙: T-0001부터 단조 증가. plan add 시 부여되는 ID는 plan/progress 양쪽에서 고유합니다.\n\n## guideline.md\n- plan/progress를 수행할 때 지켜야 할 실행 기준.\n`),
462
548
  '.harness/current-state.md': fm('current-state', ['세션 시작','작업 이어받기'], ['현재 상태 변경','다음 작업 변경'], `# Current State\n\nUpdated: ${today()}\n\n## Now\n-\n\n## Next\n-\n\n## Blockers\n-\n`),
@@ -639,7 +725,7 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
639
725
  - ⚠ contract verify 생략 → 사양 불일치 BUG가 사용자에게 노출
640
726
  `),
641
727
  '.harness/anti-lazy-work-policy.md': fm('anti-lazy-work-policy', ['완료 선언 전'], ['게으른 작업 방지 기준 변경'], `# Anti Lazy Work Policy\n\n## Rules\n1. **증거 없는 완료 금지**: \"완료\"를 선언하려면 progress-tracker의 evidence 컬럼에 명령 출력/테스트 결과/스크린샷 경로 등이 있어야 합니다.\n2. **빈 핸드오프 금지**: 세션 종료 시 session-handoff.md의 Completed/In Progress/Next Exact Step이 모두 비어 있으면 close가 \"insufficient\" 상태로 표시됩니다.\n3. **부분 구현 자기보고**: 완전 구현이 아니면 status를 \`incomplete\`로, Next Exact Step에 \"무엇을 추가해야 끝나는지\" 한 줄을 적습니다.\n4. **검증 기록**: typecheck/lint/test 결과를 review-evidence.md에 누적 기록합니다.\n5. **TODO 표지**: 코드에 \`TODO\`/\`FIXME\`/\`XXX\`를 새로 도입하면 progress-tracker에 동일 ID로 추적합니다.\n6. **거짓 완료 자동 감지**: \`leerness lazy detect\`는 다음을 자동 점검합니다.\n - progress-tracker에 done인데 evidence가 비어있는 row\n - session-handoff의 Completed가 비어있고 Next Exact Step도 비어있음\n - 코드에 새 TODO/FIXME 추가 + progress-tracker에 추적 항목 없음\n - test 명령 실행 흔적 없음 (review-evidence.md 또는 task-log.md에 명령 기록)\n7. **품질 렌즈 자가질문**: 완료 선언 전 \`leerness lens\`의 분야별 질문에 스스로 답합니다 — 코드: "선임 개발자가 이 코드를 보고 복잡하다고 느끼지 않을까?" / 디자인: "선임 디자이너와 일반 사용자가 봤을 때 이쁘고 편하고 직관적인가?". "그렇다(통과)"라고 답할 수 없으면 완료가 아닙니다. 분야를 바꾸면 인과관계로 연결된 분야(\`lens\` 출력의 ↔ 인과)의 질문도 다시 확인합니다.\n`),
642
- '.harness/rules.md': _rulesHeader() + '\n',
728
+ '.harness/rules.md': _rulesHeader(lang) + '\n', // 1.36.63: lang 전달
643
729
  '.harness/session-handoff.md': fm('session-handoff', ['세션 시작','다음 작업 이어받기'], ['세션 종료'], `# Session Handoff\n\nLast generated: (자동)\n\n## Completed\n-\n\n## In Progress\n-\n\n## Incomplete / Waiting / On Hold / Blocked\n-\n\n## Dropped\n-\n\n## Verification\n-\n\n## Recommended Direction\n-\n\n## Next Exact Step\n-\n`),
644
730
  '.harness/leerness-maintenance.md': fm('leerness-maintenance', ['작업 시작','마이그레이션/릴리즈 전'], ['버전 정책 변경'], `# Leerness Maintenance\n\nAI agents should check:\n\n\`\`\`bash\nleerness --version\nleerness self check .\nleerness update --check # 24h 캐시 자동 감지\nleerness update --yes # 새 버전 발견 시 자동 마이그레이션\ncat .harness/HARNESS_VERSION\nnpm view leerness version\n\`\`\`\n`),
645
731
  '.harness/language-policy.md': fm('language-policy', ['문서 작성 전'], ['언어 변경'], `# Language Policy\n\nSelected language: ${lang}\n\n모든 Leerness 노트, 스킬 노트, 세션 보고, 작업 목록은 위 언어를 기본으로 사용합니다 (사용자가 다른 언어를 명시 요청 시 예외).\n`),
@@ -861,7 +947,8 @@ function syncReadme(root) {
861
947
  const p = path.join(root, 'README.md');
862
948
  const existing = exists(p) ? read(p) : '';
863
949
  // 1.9.40: 자체 README도 동기화 — version 배지, e2e 카운트, package.json#version 일관성
864
- let updated = mergeReadmeSection(existing, managedReadmeBlock(detectProjectName(root)));
950
+ const _rmLang = (() => { try { return read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch { return 'ko'; } })(); // 1.36.63 (F-05 5회차)
951
+ let updated = mergeReadmeSection(existing, managedReadmeBlock(detectProjectName(root), _rmLang));
865
952
  try {
866
953
  // package.json#version 또는 .harness/HARNESS_VERSION을 참조하여 README 배지 자동 갱신
867
954
  const pkgPath = path.join(root, 'package.json');
@@ -1254,7 +1341,8 @@ async function install(root, opts = {}) {
1254
1341
  const linked = rows.some(r => /M-0001/.test(r.evidence));
1255
1342
  if (!linked) {
1256
1343
  const tid = nextId(root, 'T');
1257
- upsertProgress(root, { id: tid, status: 'planned', request: '프로젝트 계획 정리', evidence: 'init default plan:M-0001', nextAction: 'project-brief.md를 실제 목적으로 업데이트' });
1344
+ const _en0 = _uiLang(root) === 'en'; // 1.36.63: 시드 task 프로젝트 언어
1345
+ upsertProgress(root, { id: tid, status: 'planned', request: _en0 ? 'Organize the project plan' : '프로젝트 계획 정리', evidence: 'init default plan:M-0001', nextAction: _en0 ? 'Fill project-brief.md with the real purpose' : 'project-brief.md를 실제 목적으로 업데이트' });
1258
1346
  }
1259
1347
  }
1260
1348
  } catch {}
@@ -3053,7 +3141,7 @@ function _selfTestCases() {
3053
3141
  { name: '_cliBootstrap: CLI 부작용 require.main 가드 격리 (Codex #4 UR-0037 1.9.290)', run: () => { if (typeof _cliBootstrap !== 'function' || typeof _ensureStdoutEncoding !== 'function') return false; const src = read(__filename); const guarded = /if \(require\.main === module\) _cliBootstrap\(\);/.test(src); const inFn = /function _cliBootstrap\(\)\s*\{[\s\S]*?removeAllListeners\('warning'\)[\s\S]*?NODE_OPTIONS[\s\S]*?_ensureStdoutEncoding\(\);[\s\S]*?\n\}/.test(src); const noTopIife = !/\}\)\(\);\s*\n\n\/\/ 1\.9\.184/.test(src); return guarded && inFn && noTopIife; } },
3054
3142
  { name: 'lib/agent-registry: EXTERNAL_AGENTS/AGENT_SLASH_COMMANDS 모듈 단일출처 분리 (UR-0025 1.9.291)', run: () => { const m = require('../lib/agent-registry'); return m.EXTERNAL_AGENTS === EXTERNAL_AGENTS && m.AGENT_SLASH_COMMANDS === AGENT_SLASH_COMMANDS && m.EXTERNAL_AGENTS.length === 10 && Object.keys(m.AGENT_SLASH_COMMANDS).length === 9 && !/const EXTERNAL_AGENTS = \[/.test(read(__filename)); } },
3055
3143
  { name: 'get_project_context: MCP 시맨틱 verb 등록 + CLI context 디스패치 (UR-0031 1.9.292)', run: () => { const src = read(__filename); const mcpDef = require('../lib/mcp-tools').some(t => t.name === 'leerness_get_project_context'); const mcpCase = /case 'leerness_get_project_context':[\s\S]*?cliArgs = \['context'/.test(src); const cliDisp = /if \(cmd === 'context'\)\s+return contextCmd/.test(src); return typeof contextCmd === 'function' && mcpDef && mcpCase && cliDisp && _mcpToolCount() >= 80; } },
3056
- { name: '_canonicalProgressHeader + idempotency auto-fix (근본 복제버그 fix 1.9.293)', run: () => { const h = _canonicalProgressHeader(); const headerOk = /leernessRole: progress-tracker/.test(h) && /\| ID \| Status \| Request \|/.test(h) && /\|---\|/.test(h); const src = read(__filename); const fnOk = typeof _autoFixIdempotency === 'function'; const noWholeTextFallback = /if \(idx < 0\) return _canonicalProgressHeader\(\);/.test(src); const driftWired = /_autoFixIdempotency\(root\)/.test(src) && /idempotency 중복/.test(src); return headerOk && fnOk && noWholeTextFallback && driftWired; } },
3144
+ { name: '_canonicalProgressHeader + idempotency auto-fix (근본 복제버그 fix 1.9.293)', run: () => { const h = _canonicalProgressHeader(); const headerOk = /leernessRole: progress-tracker/.test(h) && /\| ID \| Status \| Request \|/.test(h) && /\|---\|/.test(h); const src = read(__filename); const fnOk = typeof _autoFixIdempotency === 'function'; const noWholeTextFallback = src.includes('return _canonicalProgressHeader(_phLang);') && !/if \(idx < 0\) return text;/.test(src); const driftWired = /_autoFixIdempotency\(root\)/.test(src) && /idempotency 중복/.test(src); return headerOk && fnOk && noWholeTextFallback && driftWired; } },
3057
3145
  { name: 'lib/role-catalog: ROLE/PROVIDER/ALIASES/PROMPTS 모듈 단일출처 분리 (UR-0025 1.9.294)', run: () => { const m = require('../lib/role-catalog'); return m.ROLE_CATALOG === ROLE_CATALOG && m._PROVIDER_MODEL_CATALOG === _PROVIDER_MODEL_CATALOG && m._ROLE_ALIASES === _ROLE_ALIASES && m._AGENT_ROLE_PROMPTS === _AGENT_ROLE_PROMPTS && Object.keys(m.ROLE_CATALOG).length === 7 && Object.keys(m._PROVIDER_MODEL_CATALOG).length === 10 && !/const ROLE_CATALOG = \{/.test(read(__filename)); } },
3058
3146
  { name: 'lib/catalogs: CAPABILITY/ADAPTERS/REUSE 모듈 단일출처 분리 (UR-0025 1.9.295) + i18n en(1.31.3)', run: () => { const m = require('../lib/catalogs'); const _H = /[가-힣]/; const cs = Object.values(m.CAPABILITY_SURFACE); const i18nOk = cs.length === 6 && cs.every(v => typeof v.descEn === 'string' && v.descEn.length > 0 && !_H.test(v.descEn) && typeof v.optOutEn === 'string' && !_H.test(v.optOutEn)) && m.POWERFUL_COMMANDS.every(c => typeof c.noteEn === 'string' && c.noteEn.length > 0 && !_H.test(c.noteEn)); return m.CAPABILITY_SURFACE === CAPABILITY_SURFACE && m.ADAPTERS === ADAPTERS && m.REUSE_CATEGORIES === REUSE_CATEGORIES && m.REUSE_CHECKLIST === REUSE_CHECKLIST && m.POWERFUL_COMMANDS === POWERFUL_COMMANDS && Object.keys(m.CAPABILITY_SURFACE).length === 6 && i18nOk && !/const CAPABILITY_SURFACE = \{/.test(read(__filename)); } },
3059
3147
  { name: 'about: 정체성 verb(AI 운영 레이어) + MCP leerness_about 등록 (UR-0030 1.9.296)', run: () => { const id = _leernessIdentity(); const src = read(__filename); return typeof aboutCmd === 'function' && /운영 레이어/.test(id.identity) && id.layers.length === 5 && id.surface.mcpTools >= 81 && require('../lib/mcp-tools').some(t => t.name === 'leerness_about') && /case 'leerness_about':/.test(src) && /cmd === 'about' \|\| cmd === 'identity'/.test(src); } },
@@ -3737,7 +3825,7 @@ function _selfTestCases() {
3737
3825
  { name: 'UR-0083(4th외부평가 9.3): auto-update hook 비침투 (update --quiet 모드 + hook --check --quiet + 업그레이드)', run: () => { const src = read(__filename); const quietMode = /const quiet = !!opts\.quiet \|\| has\('--quiet'\)/.test(src); const hookQuiet = src.includes("command: 'leerness update --check --quiet'"); const upgrade = /includes\('leerness update --check'\) && !h\.command\.includes\('--quiet'\)/.test(src); return quietMode && hookQuiet && upgrade; } },
3738
3826
  { name: 'CV-6/UR-0081: 시크릿 스캐너 FP/FN — _isPlaceholderSecret + _looksSecretLike 행위', run: () => { if (typeof _isPlaceholderSecret !== 'function' || typeof _looksSecretLike !== 'function') return false; const fp = _isPlaceholderSecret('change-me') && _isPlaceholderSecret('your-api-key-here') && _isPlaceholderSecret('<token>') && _isPlaceholderSecret('') && !_isPlaceholderSecret('hunter2realpass'); const fn = _looksSecretLike('secret123') && _looksSecretLike('a'.repeat(24)) && !_looksSecretLike('processEnv') && !_looksSecretLike('reqBodyPassword'); return fp && fn; } },
3739
3827
  { name: 'UR-0025: _mergeLines/_mergeEnvLines 순수 코어 모듈 분리 + 행위 (1.9.367)', run: () => { if (typeof _mergeLines !== 'function' || typeof _mergeEnvLines !== 'function') return false; const m = require('../lib/pure-utils'); const moved = m._mergeLines === _mergeLines && m._mergeEnvLines === _mergeEnvLines; const ml = _mergeLines('a\n', ['a', 'b']) === 'a\nb\n'; const meKeep = _mergeEnvLines('FOO=keep\n', ['FOO=new']) === 'FOO=keep\n'; const meAdd = _mergeEnvLines('FOO=keep\n', ['BAR=add']).includes('BAR=add'); return moved && ml && meKeep && meAdd; } },
3740
- { name: 'UR-0025: _mergeReadmeSection/_managedMerge + MERGE_OVERWRITE_FILES 모듈 분리 + 행위 (1.9.368)', run: () => { const m = require('../lib/pure-utils'); const c = require('../lib/catalogs'); if (typeof _mergeReadmeSection !== 'function' || typeof _managedMerge !== 'function') return false; const moved = m._mergeReadmeSection === _mergeReadmeSection && m._managedMerge === _managedMerge && MERGE_OVERWRITE_FILES === c.MERGE_OVERWRITE_FILES; const rd = _mergeReadmeSection('', 'BLOCK', '<s>', '<e>') === '# Project\n\nBLOCK'; const mm = _managedMerge('a.md', 'NEW', 'OLD', '.h', new Set()).includes('migration-preserved'); const ow = _managedMerge('.harness/manifest.json', 'NEW', 'OLD', '.h', c.MERGE_OVERWRITE_FILES) === 'NEW'; const same = _managedMerge('a.md', 'X', 'X', '.h', new Set()) === 'X'; return moved && rd && mm && ow && same; } },
3828
+ { name: 'UR-0025: _mergeReadmeSection/_managedMerge + MERGE_OVERWRITE_FILES 모듈 분리 + 행위 (1.9.368)', run: () => { const m = require('../lib/pure-utils'); const c = require('../lib/catalogs'); if (typeof _mergeReadmeSection !== 'function' || typeof _managedMerge !== 'function') return false; const moved = m._mergeReadmeSection === _mergeReadmeSection && m._managedMerge === _managedMerge && MERGE_OVERWRITE_FILES === c.MERGE_OVERWRITE_FILES; const rd = _mergeReadmeSection('', 'BLOCK', '<s>', '<e>') === '# Project\n\nBLOCK\n'; const mm = _managedMerge('a.md', 'NEW', 'OLD', '.h', new Set()).includes('migration-preserved'); const ow = _managedMerge('.harness/manifest.json', 'NEW', 'OLD', '.h', c.MERGE_OVERWRITE_FILES) === 'NEW'; const same = _managedMerge('a.md', 'X', 'X', '.h', new Set()) === 'X'; return moved && rd && mm && ow && same; } },
3741
3829
  { name: 'UR-0025: _parseSkillsValue(catalog 주입) + MINIMAL_SKIP_KEYS 모듈 분리 + 행위 (1.9.369)', run: () => { const m = require('../lib/pure-utils'); const c = require('../lib/catalogs'); if (typeof _parseSkillsValue !== 'function') return false; const moved = m._parseSkillsValue === _parseSkillsValue && MINIMAL_SKIP_KEYS === c.MINIMAL_SKIP_KEYS; const cat = { office: {}, foo: {} }; const empty = _parseSkillsValue('', cat).length === 0; const all = _parseSkillsValue('all', cat).length === 2; const rec = _parseSkillsValue('recommended', cat).includes('office'); const csv = JSON.stringify(_parseSkillsValue('office,bar', cat)) === JSON.stringify(['office']); return moved && empty && all && rec && csv && MINIMAL_SKIP_KEYS.has('.claude/skills/leerness.md'); } },
3742
3830
  { name: 'UR-0025: _parseArchiveBlocks/_parseSkillCatalog 순수 파서 모듈 분리 + 행위 (1.9.370)', run: () => { const m = require('../lib/pure-utils'); if (typeof _parseArchiveBlocks !== 'function' || typeof _parseSkillCatalog !== 'function') return false; const moved = m._parseArchiveBlocks === _parseArchiveBlocks && m._parseSkillCatalog === _parseSkillCatalog; const ab = _parseArchiveBlocks('## 제거 2026-01-01 (target: ' + '"T-1")\n### 헤더\n'); const abOk = ab.length === 1 && ab[0].date === '2026-01-01' && ab[0].target === 'T-1' && ab[0].originalHeader === '헤더'; const md = _parseSkillCatalog('- [nm](https://x/SKILL.md) — d', ''); const mdOk = md.length === 1 && md[0].name === 'nm' && md[0].format === 'markdown'; const js = _parseSkillCatalog('{' + '"skills":[{"id":"a","url":"u"}]}', ''); const jsOk = js.length === 1 && js[0].name === 'a' && js[0].format === 'json'; return moved && abOk && mdOk && jsOk; } },
3743
3831
  { name: 'UR-0073 Phase A: team 정의 레지스트리 (_renderTeamsMd + canonical load/save) 행위 (1.9.371)', run: () => { const m = require('../lib/pure-utils'); if (typeof teamCmd !== 'function' || typeof _renderTeamsMd !== 'function' || m._renderTeamsMd !== _renderTeamsMd) return false; const md = _renderTeamsMd([{ id: 't1', name: 'N', personas: ['security'], members: ['claude'], schedule: 'daily', status: 'active' }]); const mdOk = md.includes('## t1') && md.includes('security') && md.includes('daily') && md.includes('정의 전용'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_team_')); let rtOk = false; try { _saveTeams(tmp, [{ id: 'x', name: 'X', personas: [], members: [], schedule: 'manual', status: 'active' }]); const loaded = _loadTeams(tmp); rtOk = loaded.length === 1 && loaded[0].id === 'x' && fs.existsSync(path.join(tmp, '.harness', 'teams.json')) && fs.existsSync(path.join(tmp, '.harness', 'teams.md')); } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return mdOk && rtOk; } },
@@ -5285,6 +5373,34 @@ function _selfTestCases() {
5285
5373
  const idem = (m3.match(/MY CUSTOM LINE/g) || []).length === 1;
5286
5374
  const wired = read(__filename).includes('altTemplate: _altFiles[f]') && read(__filename).includes("'.harness/session-workflow.md', // 1.36.60");
5287
5375
  return noKoTemplate && customKept && enWrapper && control && idem && wired;
5376
+ } },
5377
+ { name: 'lint 게이트 (1.36.64, 외부감사 F-10): 구문/U+2028(실사고 재발 차단)/BOM/JSON — 클린 통과 + 위험문자 검출 행위검사 + npm test 배선', run: () => {
5378
+ const lintP = path.join(__dirname, '..', 'scripts', 'lint.js');
5379
+ if (!exists(lintP)) return false;
5380
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lint_'));
5381
+ try {
5382
+ fs.mkdirSync(path.join(tmp, 'lib'), { recursive: true });
5383
+ fs.writeFileSync(path.join(tmp, 'lib', 'ok.js'), 'module.exports = 1;\n');
5384
+ const r1 = cp.spawnSync(process.execPath, [lintP, tmp], { encoding: 'utf8', timeout: 60000 });
5385
+ const cleanOk = r1.status === 0;
5386
+ fs.writeFileSync(path.join(tmp, 'lib', 'bad.js'), 'const s = "a' + String.fromCharCode(0x2028) + 'b";\n');
5387
+ const r2 = cp.spawnSync(process.execPath, [lintP, tmp], { encoding: 'utf8', timeout: 60000 });
5388
+ const caught = r2.status === 1 && /U\+2028/.test(r2.stdout);
5389
+ // (검수 #2) 4개 축 전부 행위 검증 — 구문/BOM/JSON 도 각각 검출
5390
+ fs.writeFileSync(path.join(tmp, 'lib', 'bad.js'), 'const = ;\n');
5391
+ const rSyn = cp.spawnSync(process.execPath, [lintP, tmp], { encoding: 'utf8', timeout: 60000 });
5392
+ const synCaught = rSyn.status === 1 && /syntax:/.test(rSyn.stdout);
5393
+ fs.writeFileSync(path.join(tmp, 'lib', 'bad.js'), Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from('module.exports=1;\n')]));
5394
+ const rBom = cp.spawnSync(process.execPath, [lintP, tmp], { encoding: 'utf8', timeout: 60000 });
5395
+ const bomCaught = rBom.status === 1 && /bom:/.test(rBom.stdout);
5396
+ fs.writeFileSync(path.join(tmp, 'lib', 'bad.js'), 'module.exports=1;\n');
5397
+ fs.writeFileSync(path.join(tmp, 'lib', 'x.json'), '{broken');
5398
+ const rJson = cp.spawnSync(process.execPath, [lintP, tmp], { encoding: 'utf8', timeout: 60000 });
5399
+ const jsonCaught = rJson.status === 1 && /json:/.test(rJson.stdout);
5400
+ const pkg = JSON.parse(read(path.join(__dirname, '..', 'package.json')));
5401
+ const wired = pkg.scripts.lint === 'node ./scripts/lint.js' && ['test', 'test:fast', 'test:core'].every(k => pkg.scripts[k].startsWith('node ./scripts/lint.js &&')); // (검수 #1) test:core 포함
5402
+ return cleanOk && caught && synCaught && bomCaught && jsonCaught && wired;
5403
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5288
5404
  } }
5289
5405
  ];
5290
5406
  }
@@ -8941,7 +9057,12 @@ function readProgressRows(root) {
8941
9057
  return rows;
8942
9058
  }
8943
9059
  // 1.9.293: progress-tracker 정식 헤더 (frontmatter + 표 헤더 + 분리자). coreFiles 와 단일 출처.
8944
- function _canonicalProgressHeader() {
9060
+ function _canonicalProgressHeader(lang = 'ko') {
9061
+ // 1.36.63 (F-05 5회차): en frontmatter — 표 헤더/파서는 원래 영어·위치 기반이라 동작 무변
9062
+ if (lang === 'en') {
9063
+ return fm('progress-tracker', ['session start', 'session close', 'checking request status'], ['task status changes', 'verification results added', 'user request dropped'],
9064
+ `# Progress Tracker\n\nStatus values: requested, planned, in-progress, waiting, on-hold, blocked, incomplete, done, dropped\n\n| ID | Status | Request | Evidence | Next Action | Updated |\n|---|---|---|---|---|---|\n`, 'en').trimEnd();
9065
+ }
8945
9066
  return fm('progress-tracker', ['세션 시작', '세션 종료', '사용자 요청 상태 확인'], ['작업 상태 변경', '검증 결과 추가', '사용자 요청 드랍'],
8946
9067
  `# Progress Tracker\n\nStatus values: requested, planned, in-progress, waiting, on-hold, blocked, incomplete, done, dropped\n\n| ID | Status | Request | Evidence | Next Action | Updated |\n|---|---|---|---|---|---|\n`).trimEnd();
8947
9068
  }
@@ -8950,7 +9071,11 @@ function progressHeader(root) {
8950
9071
  const idx = text.indexOf('|---|');
8951
9072
  // 1.9.293 (근본 버그 fix): 분리자 없음(손상/헤더유실) 시 전체 텍스트 반환은 writeProgressRows 가 행을 복제 →
8952
9073
  // 세션마다 중복 누적의 원인이었음. 정식 헤더를 재구성해 반환 → 다음 write 시 자동 복구.
8953
- if (idx < 0) return _canonicalProgressHeader();
9074
+ if (idx < 0) {
9075
+ // 1.36.63 (검수 #1): 손상 헤더 복구도 프로젝트 언어로 — en 프로젝트가 ko frontmatter 로 회귀하지 않게
9076
+ let _phLang = 'ko'; try { _phLang = read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch {}
9077
+ return _canonicalProgressHeader(_phLang);
9078
+ }
8954
9079
  return text.slice(0, text.indexOf('\n', idx)).trimEnd();
8955
9080
  }
8956
9081
  function writeProgressRows(root, header, rows) {
@@ -9040,9 +9165,9 @@ function planListCmd(root, opts = {}) {
9040
9165
  function planAdd(root, text) {
9041
9166
  if (!_requireInit(root, 'plan add')) return; // 1.9.311 (UR-0047): init 가드
9042
9167
  if (!_validateChoice(arg('--status', null), TASK_STATUSES, 'plan status')) { process.exitCode = 1; return; } // 1.9.310 (UR-0046)
9043
- const status = arg('--status','planned'), progress = arg('--progress','0'), nextAction = arg('--next', '다음 액션 작성');
9168
+ const status = arg('--status','planned'), progress = arg('--progress','0'), nextAction = arg('--next', _uiLang(root) === 'en' ? 'write the next action' : '다음 액션 작성'); // 1.36.63
9044
9169
  // 1.14.2 (Karpathy 원칙4 "성공기준 정의", UR-0032): --done-when 으로 검증가능 완료조건을 milestone 에 기록. 미지정 시 (미정) — plan show/audit 가 환기.
9045
- const doneWhen = _lineSafe(arg('--done-when', '') || '(미정)');
9170
+ const doneWhen = _lineSafe(arg('--done-when', '') || (_uiLang(root) === 'en' ? '(unset)' : '(미정)')); // 1.36.63 (검수 #2)
9046
9171
  // 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
9047
9172
  const { id, tid } = _withLock(progressPath(root), () => {
9048
9173
  const id = nextId(root, 'M');
@@ -9058,7 +9183,7 @@ function planAdd(root, text) {
9058
9183
  }
9059
9184
  function planDrop(root, text) {
9060
9185
  const id = nextId(root, 'D');
9061
- const reason = arg('--reason', '사용자 요청으로 제외');
9186
+ const reason = arg('--reason', _uiLang(root) === 'en' ? 'dropped by user request' : '사용자 요청으로 제외'); // 1.36.63 (검수 #2)
9062
9187
  // 1.10.4 (13th 버그헌트 P3, UR-0170): text/reason 의 파이프(|)·개행이 plan.md 마크다운 표 칼럼을 깨뜨림 → _cellSafe 셀 안전화(task/rule UR-0104 와 동일).
9063
9188
  const safeText = _cellSafe(text); const safeReason = _cellSafe(reason);
9064
9189
  const planFile = planPath(root); let p = exists(planFile) ? read(planFile) : '';
@@ -9226,7 +9351,7 @@ function taskAdd(root, text) {
9226
9351
  // 1.9.303 (UR-0043): ID 할당 + write 를 하나의 락으로 — 동시 task add 의 ID 충돌(lost-update) 방지.
9227
9352
  const id = _withLock(progressPath(root), () => {
9228
9353
  const newId = nextId(root, 'T');
9229
- upsertProgress(root, { id: newId, status: _normTaskStatus(arg('--status','requested')), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
9354
+ upsertProgress(root, { id: newId, status: _normTaskStatus(arg('--status','requested')), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next', _uiLang(root) === 'en' ? 'write the next action' : '다음 액션 작성') }); // 1.36.63: 기본값 언어 인지
9230
9355
  return newId;
9231
9356
  });
9232
9357
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 시 구조화 출력(코어 데이터는 이미 영속). 사람용 ok + cosmetic roadmap/interactive review 는 스킵.
@@ -9322,7 +9447,8 @@ function taskDrop(root, id) {
9322
9447
  const rows = readProgressRows(root);
9323
9448
  // 1.9.396 (6번째 외부평가/codex P1-B): 없는 task drop 시 가짜 row(request undefined) 생성 = 데이터 손상 → task update 와 동일하게 존재 확인 후 fail(no-op).
9324
9449
  if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
9325
- upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
9450
+ const _tdEn = _uiLang(root) === 'en'; // 1.36.63 (검수 #2)
9451
+ upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason', _tdEn ? 'dropped by user request' : '사용자 요청으로 제외'), nextAction: _tdEn ? 'none' : '없음' });
9326
9452
  ok(`task dropped: ${id}`);
9327
9453
  _autoRoadmap(absRoot(root), 'data-change');
9328
9454
  }
@@ -10268,7 +10394,7 @@ function lazyDetect(root, opts = {}) {
10268
10394
  }
10269
10395
  // 1.35.16 (lazy 헌트 우회 #9): status 대소문자무관(Blocked) + nextAction trivial 토큰(빈값/-/n/a/none/tbd/점만) 도 blocker_no_next_action 으로 감지(기존 '없음'/'다음 액션 작성' 만).
10270
10396
  const blockers = rows.filter(r => /^blocked$/i.test(r.status || ''));
10271
- for (const b of blockers) { const _na = (b.nextAction || '').trim(); if (!_na || _na === '없음' || /다음 액션 작성/.test(b.nextAction) || /^(-+|n\/?a|none|nil|tbd|todo|wip|x|\.+|\?+)$/i.test(_na)) {
10397
+ for (const b of blockers) { const _na = (b.nextAction || '').trim(); if (!_na || _na === '없음' || /다음 액션 작성|write the next action/i.test(b.nextAction) || /^(-+|n\/?a|none|nil|tbd|todo|wip|x|\.+|\?+)$/i.test(_na)) { // 1.36.63: en 기본값도 trivial 인식
10272
10398
  issues++; _warn(`blocker without nextAction: ${b.id}`,
10273
10399
  { kind: 'blocker_no_next_action', severity: 'warn', taskId: b.id });
10274
10400
  } }
@@ -16064,7 +16190,20 @@ function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
16064
16190
  function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
16065
16191
  function rulesCachePath(root) { return path.join(root, '.harness/cache/rule-state.json'); }
16066
16192
 
16067
- function _rulesHeader() {
16193
+ function _rulesHeader(lang = 'ko') {
16194
+ // 1.36.63 (F-05 5회차): en 프로젝트 rules 헤더 영어 — 파서(readRules)는 '| R-' 라인앵커+위치 기반이라 라벨 무관
16195
+ if (lang === 'en') {
16196
+ return [
16197
+ '---', 'leernessRole: rules', 'readWhen:', ' - session start (handoff)', ' - before every task', ' - before completing every task', ' - session close',
16198
+ 'updateWhen:', ' - user states a new natural-language rule', ' - user asks to pause/remove a rule',
16199
+ 'doNotStore:', ' - real tokens', ' - passwords', ' - production cookies', ' - raw sensitive personal data', '---',
16200
+ '<!-- leerness:managed -->', '# User Rules', '',
16201
+ 'Standing user-defined rules the AI agent must follow every session and every task.',
16202
+ 'All active rules are auto-surfaced and verified each session until the user explicitly says stop/remove.',
16203
+ '', '## Active Rules', '',
16204
+ '| ID | Trigger | Rule | Added | Status | Last Verified |', '|---|---|---|---|---|---|'
16205
+ ].join('\n');
16206
+ }
16068
16207
  return [
16069
16208
  '---',
16070
16209
  'leernessRole: rules',
@@ -16130,7 +16269,9 @@ function writeRules(root, rules) {
16130
16269
  // 1.36.38 (#1): 파싱 불가 행은 무언 삭제 대신 원문 보존 + 경고 표식 (수동 복구 대상)
16131
16270
  const damaged = _unparsableRuleLines(root).filter(l => !rules.some(r => l.startsWith(`| ${r.id} `)));
16132
16271
  const tail = damaged.length ? '\n<!-- ⚠ 아래 행은 손상돼 파싱 불가 — 삭제하지 않고 보존함. 수동 복구 후 위 표로 병합하세요 -->\n' + damaged.join('\n') + '\n' : '';
16133
- writeUtf8(rulesPath(root), _rulesHeader() + '\n' + body + (body ? '\n' : '') + tail);
16272
+ // 1.36.63: 저장 헤더 언어 = 프로젝트 언어 (en rules.md rule add ko 회귀하지 않게)
16273
+ let _rlLang = 'ko'; try { _rlLang = read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch {}
16274
+ writeUtf8(rulesPath(root), _rulesHeader(_rlLang) + '\n' + body + (body ? '\n' : '') + tail);
16134
16275
  }
16135
16276
 
16136
16277
  function nextRuleId(root) {
package/lib/pure-utils.js CHANGED
@@ -758,10 +758,12 @@ function _mergeEnvLines(currentText, lines) {
758
758
 
759
759
  // 1.9.368 (UR-0025): README 관리섹션 머지 순수 코어 — 마커 사이 교체, 없으면 append. 마커는 인자로 주입(harness 상수 비결합).
760
760
  function _mergeReadmeSection(existing, block, START, END) {
761
- if (!existing) return `# Project\n\n${block}`;
761
+ // 1.36.63 (검수 #4): 블록 말단 개행을 정규화해 재실행 바이트 멱등 — 종전엔 1차 재init 가 빈 줄 1개를 더했다
762
+ block = String(block).trimEnd();
763
+ if (!existing) return `# Project\n\n${block}\n`;
762
764
  const s = existing.indexOf(START); const e = existing.indexOf(END);
763
765
  if (s >= 0 && e >= s) return existing.slice(0, s).trimEnd() + '\n\n' + block + '\n' + existing.slice(e + END.length).trimStart();
764
- return existing.trimEnd() + '\n\n' + block;
766
+ return existing.trimEnd() + '\n\n' + block + '\n';
765
767
  }
766
768
  // 1.9.368 (UR-0025): 관리 파일 마이그레이션 머지 순수 코어 — 이전 내용을 migration-preserved 블록으로 보존(데이터/인덱스 파일은 overwrite).
767
769
  // archiveRel(사전 계산된 표시 경로) + overwriteSet 을 인자로 주입 → path/process/상수 비결합(순수).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.62",
3
+ "version": "1.36.64",
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",
@@ -48,10 +48,11 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e.js",
52
- "test:core": "node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js",
53
- "test:fast": "node ./bin/leerness.js selftest && node ./scripts/smoke.js",
51
+ "test": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js",
53
+ "test:fast": "node ./scripts/lint.js && node ./bin/leerness.js selftest && node ./scripts/smoke.js",
54
54
  "test:smoke": "node ./scripts/smoke.js",
55
+ "lint": "node ./scripts/lint.js",
55
56
  "prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
56
57
  },
57
58
  "publishConfig": {
@@ -0,0 +1,63 @@
1
+ // scripts/lint.js — 0-deps 경량 lint 게이트 (1.36.64, 외부감사 F-10: lint/typecheck 부재)
2
+ // 범위: 배포되는 모든 .js 의 ① node --check 구문 ② 위험 문자(U+2028/U+2029 — 과거 raw U+2028 정규식 리터럴이
3
+ // 전 명령 SyntaxError 를 일으킨 실사고 재발 차단) ③ BOM ④ 저장소 .json 파싱 유효성.
4
+ // 빠르고(수 초) CI 친화(exit 1). npm run lint / test:fast 에 배선.
5
+ 'use strict';
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const cp = require('child_process');
9
+
10
+ const ROOT = path.resolve(process.argv[2] || path.join(__dirname, '..')); // 테스트용 루트 오버라이드 허용
11
+ const TARGET_DIRS = ['bin', 'lib', 'scripts'];
12
+ const failures = [];
13
+
14
+ function listFiles(dir, ext) {
15
+ const out = [];
16
+ const walk = (d) => {
17
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
18
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
19
+ const p = path.join(d, e.name);
20
+ if (e.isDirectory()) walk(p);
21
+ else if (e.name.endsWith(ext)) out.push(p);
22
+ }
23
+ };
24
+ if (fs.existsSync(dir)) walk(dir);
25
+ return out;
26
+ }
27
+
28
+ const jsFiles = TARGET_DIRS.flatMap(d => listFiles(path.join(ROOT, d), '.js'));
29
+
30
+ // ① 구문 (node --check)
31
+ for (const f of jsFiles) {
32
+ const r = cp.spawnSync(process.execPath, ['--check', f], { encoding: 'utf8', timeout: 30000 });
33
+ if (r.error) { failures.push(`syntax-check spawn failed: ${path.relative(ROOT, f)} — ${r.error.code || r.error.message}`); continue; } // (검수 #3) 스폰 실패를 구문오류와 구분
34
+ if (r.status !== 0) {
35
+ const lines = (r.stderr || '').split('\n');
36
+ const detail = lines.find(l => /Error/.test(l)) || lines[0] || ''; // (검수 #3) SyntaxError 본문 우선
37
+ failures.push(`syntax: ${path.relative(ROOT, f)} — ${detail.trim()}`);
38
+ }
39
+ }
40
+
41
+ // ② 위험 문자 + ③ BOM
42
+ for (const f of jsFiles) {
43
+ const buf = fs.readFileSync(f);
44
+ if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) failures.push(`bom: ${path.relative(ROOT, f)}`);
45
+ const text = buf.toString('utf8');
46
+ const ls = text.indexOf('\u2028'); const ps = text.indexOf('\u2029'); // 이스케이프 표기 — 자기참조 트랩 방지
47
+ if (ls >= 0) failures.push(`U+2028 line-separator: ${path.relative(ROOT, f)}:${text.slice(0, ls).split('\n').length} (과거 전-명령 SyntaxError 실사고 문자)`);
48
+ if (ps >= 0) failures.push(`U+2029 paragraph-separator: ${path.relative(ROOT, f)}:${text.slice(0, ps).split('\n').length}`);
49
+ }
50
+
51
+ // ④ 저장소 JSON 유효성 (배포 관련 루트 + data 성격 파일)
52
+ const jsonFiles = ['package.json', ...listFiles(path.join(ROOT, 'lib'), '.json'), ...listFiles(path.join(ROOT, 'scripts'), '.json')].map(f => path.isAbsolute(f) ? f : path.join(ROOT, f));
53
+ for (const f of jsonFiles) {
54
+ if (!fs.existsSync(f)) continue;
55
+ try { JSON.parse(fs.readFileSync(f, 'utf8').replace(/^/, '')); } catch (e) { failures.push(`json: ${path.relative(ROOT, f)} — ${e.message.slice(0, 80)}`); }
56
+ }
57
+
58
+ if (failures.length) {
59
+ console.log(`✗ lint: ${failures.length} issue(s)`);
60
+ failures.forEach(x => console.log(' - ' + x));
61
+ process.exit(1);
62
+ }
63
+ console.log(`✓ lint: ${jsFiles.length} js (syntax/U+2028/BOM) + ${jsonFiles.length} json — clean`);