leerness 1.36.183 → 1.36.185

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/bin/leerness.js CHANGED
@@ -11,7 +11,7 @@ const { scanShellScriptsEncoding, applyShellScriptUtf8Bom } = require('../lib/sh
11
11
  const os = require('os'); // 1.9.178: _publishToNpm 에서 os.tmpdir() 사용 (전역 import)
12
12
  const readline = require('readline');
13
13
  // 1.9.274 (UR-0025 1단계): 순수 유틸 함수 모듈 분리 (require-based, 비파괴). selftest 7종이 동작 검증.
14
- const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue, _parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine, compareVer, parseHarnessVersion, _classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
14
+ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, redactSecrets, stripDefaultIgnorables, _mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue, _parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine, compareVer, parseHarnessVersion, _classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
15
15
  PERMISSION_TIERS, _tierRank, _requiredTier, _policyAllows, _resolveNpmTag, _mcpJsonContent, _newRunRecord,
16
16
  _htmlToText, _extractTitle, _extractLinks,
17
17
  _countDatedBlocks, _extractDecisionBlocks, _classifyIntent,
@@ -49,7 +49,7 @@ const {
49
49
  migrateLegacyWorkspace,
50
50
  } = require('../lib/workspace-dir');
51
51
 
52
- const VERSION = '1.36.183';
52
+ const VERSION = '1.36.185';
53
53
 
54
54
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
55
55
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -661,17 +661,22 @@ function _getAutoLoopRule(root) {
661
661
  return readRules(root).find(r => r.status === 'active' && /every-round/i.test(r.trigger || '')) || null;
662
662
  } catch { return null; }
663
663
  }
664
+ function _optionArgv() {
665
+ const end = process.argv.indexOf('--');
666
+ return end >= 0 ? process.argv.slice(0, end) : process.argv;
667
+ }
664
668
  function arg(name, def = null) {
665
- const i = process.argv.indexOf(name);
669
+ const optionArgs = _optionArgv();
670
+ const i = optionArgs.indexOf(name);
666
671
  if (i >= 0) {
667
672
  // 1.10.5 (13th 버그헌트 P3, UR-0169): 값 없음(끝) 또는 다음 토큰이 --장식플래그면 def 반환 — 기존 `argv[i+1] || true` 는 다음 --flag 를 값으로 흡수하거나 boolean true 를 누출(예: --reason 가 'true' 기록)했음. 음수값(-5 등 단일 -)은 값으로 보존.
668
- const next = process.argv[i + 1];
673
+ const next = optionArgs[i + 1];
669
674
  return (next != null && !next.startsWith('--')) ? next : def;
670
675
  }
671
- const eq = process.argv.find(a => a.startsWith(name + '=')); // --name=value 형태 (외부리뷰 CV-1/UR-0076)
676
+ const eq = optionArgs.find(a => a.startsWith(name + '=')); // --name=value 형태 (외부리뷰 CV-1/UR-0076)
672
677
  return eq ? eq.slice(name.length + 1) : def;
673
678
  }
674
- function has(name) { return process.argv.includes(name); }
679
+ function has(name) { return _optionArgv().includes(name); }
675
680
  // 1.36.82: 반복 지정 값 플래그를 `--flag=v` 형태까지 포함해 수집하고, **값 누락도 보고**한다.
676
681
  // (검수 High#4) 값 없는 `--require-referee` 나 `--require-referee=` 를 조용히 버리면
677
682
  // CI 의 `--require-referee=$EMPTY_ENV` 가 아무 게이팅 없이 exit 0 — fail-open 이다.
@@ -679,7 +684,7 @@ function has(name) { return process.argv.includes(name); }
679
684
  function _argAllStrict(name) {
680
685
  const values = [];
681
686
  let missing = 0;
682
- const a = process.argv;
687
+ const a = _optionArgv();
683
688
  for (let i = 0; i < a.length; i++) {
684
689
  if (a[i] === name) {
685
690
  const next = a[i + 1];
@@ -717,7 +722,13 @@ const _VALUE_FLAGS = new Set(['--language','--skills','--path','--status','--pro
717
722
  '--files-changed','--files-read','--members','--only','--personas','--priority','--purpose','--schedule','--mode',
718
723
  // --approved-by 는 `agents route` 가 값으로 쓴다(자체 인라인 목록에만 있었다) — 부울로 오분류하면 값이 토큰으로 샌다.
719
724
  // --title 도 같다(_TASK_VALUE_FLAGS 소속). 둘 다 완전성 가드가 잡아냈다 — 손으로 분류한 것은 손으로 틀린다.
720
- '--approved-by', '--title']);
725
+ '--approved-by', '--title',
726
+ // T-0172 역할 폴백·실행 provenance: 값 플래그를 중앙 파서에도 등록한다.
727
+ '--session-id','--session-provider','--session-model','--session-model-family',
728
+ '--model-family','--agent-model','--agent-model-family','--agent-session',
729
+ '--candidate','--candidate-family','--remove-candidate','--policy','--fallback-policy',
730
+ '--auth-state','--entitlement','--quota-state','--policy-state','--reachability',
731
+ '--retry-after','--expires-at','--ttl-min','--reason','--session','--ttl']);
721
732
  // 1.36.103 (codex 검수 #10/#11): `brief set` 은 플래그를 정적 리터럴이 아니라 레지스트리에서 만든다
722
733
  // (`arg('--' + f.flag)`). 정적 추출로는 보이지 않아 `brief set --intro hello` 가 "알 수 없는 플래그" 오탐을 냈다.
723
734
  // 손으로 10개를 베껴 적으면 그 목록이 또 뒤처진다 — 레지스트리에서 **유도**한다(이 라운드의 요지 그대로).
@@ -728,7 +739,7 @@ const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-pre
728
739
  // 소스에 리터럴로만 존재해 소비 형태를 기계 추출하지 못한 것들 — 실재하는 플래그이므로 오타로 오인하면 안 된다
729
740
  '--commands','--decision','--errors','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
730
741
  // lib/*.js 의 부울 소비자 — bin/ 만 훑었다면 `agents route --confirm` 같은 실재 문법에 오탐 경고를 냈을 것이다
731
- '--confirm','--dispatch','--execute','--ladder','--log','--multi','--no-env-check','--no-feature-check','--no-gitignore-check','--no-npm-audit','--no-secret-scan','--readonly','--write']);
742
+ '--confirm','--dispatch','--execute','--ladder','--log','--multi','--no-env-check','--no-feature-check','--no-gitignore-check','--no-npm-audit','--no-secret-scan','--readonly','--write','--clear-candidates','--rate-limited','--not-rate-limited']);
732
743
  // 1.36.103: 하위명령이 '선택' 인 명령에서 `<cmd> <경로>` 의 경로가 하위명령 자리로 읽히던 것 차단.
733
744
  // tech/library 는 경로가 args[1] 인데 root 해석이 _taskPositionalPath(args, 2) 로 한 칸 늦게 시작해
734
745
  // positional 을 통째로 건너뛰고 cwd 에 썼다(실측: `tech <TARGET>` 이 CWD 에 tech-profile.json 생성).
@@ -759,6 +770,10 @@ function _nonFlagArgsPass(booleanValueFlags = new Set()) {
759
770
  const a = process.argv.slice(2);
760
771
  for (let i = 0; i < a.length; i++) {
761
772
  const x = a[i];
773
+ if (x === '--') {
774
+ for (let j = i + 1; j < a.length; j++) out.push(a[j]);
775
+ break;
776
+ }
762
777
  if (x.startsWith('-')) {
763
778
  const name = x.split('=')[0];
764
779
  if (withValue.has(name) && !booleanValueFlags.has(name) && !x.includes('=') && a[i+1] && !a[i+1].startsWith('-')) i++;
@@ -792,7 +807,7 @@ function nonFlagArgs() {
792
807
  return overrides.size ? _nonFlagArgsPass(overrides) : initial;
793
808
  }
794
809
  function argAll(name) {
795
- const out = []; const a = process.argv;
810
+ const out = []; const a = _optionArgv();
796
811
  for (let i = 0; i < a.length; i++) if (a[i] === name && a[i+1] && !a[i+1].startsWith('-')) out.push(a[++i]);
797
812
  return out;
798
813
  }
@@ -851,6 +866,24 @@ function _uiLang(root) {
851
866
  } catch {}
852
867
  return 'ko';
853
868
  }
869
+ // `mode` accepts both `--path` and path-like positionals after get/set. Keep
870
+ // every pre-dispatch diagnostic, stale check, and handler branch on the same
871
+ // target instead of letting each layer guess a different argv index.
872
+ function _modeRoot(args = nonFlagArgs(), fallback = process.cwd()) {
873
+ return absRoot(arg('--path', null) || _taskPositionalPath(args, 1) || fallback);
874
+ }
875
+ // T-0164: mode machine errors predate localization and use Korean canonical
876
+ // text. Preserve that byte contract even when an error is raised by a shared
877
+ // pre-dispatch guard before the mode handler can select its own renderer.
878
+ function _errorUiLang(root, command) {
879
+ // Global flags may legally precede the command. Use the same parser as main
880
+ // instead of assuming argv[2] is the command, or `--language en mode ...`
881
+ // makes shared pre-dispatch JSON errors language-dependent again.
882
+ const parsed = nonFlagArgs();
883
+ const resolvedCommand = command || parsed[0];
884
+ if (resolvedCommand === 'mode' && has('--json')) return 'ko';
885
+ return _uiLang(resolvedCommand === 'mode' ? _modeRoot(parsed, root) : root);
886
+ }
854
887
  // ko/en 쌍에서 해석된 UI 언어로 선택 (Phase 1: 첫 화면 한정 사용).
855
888
  function _tx(lang, ko, en) { return lang === 'en' ? en : ko; }
856
889
  function fm(role, readWhen, updateWhen, body, lang = 'ko') {
@@ -1123,7 +1156,11 @@ function skillLock(skills, root) {
1123
1156
  // 생성물도 동작도 standard 와 **바이트 동일**이었다 — 이름만 있는 등급이었다. 없는 것을 광고하지 않는다.
1124
1157
  // 두 등급이 정직하게 도는 편이 셋 중 하나가 거짓말하는 것보다 낫다. strict 는 실제 게이트를 갖출 때 다시 연다.
1125
1158
  const _MODES = ['minimal', 'standard'];
1126
- function _normMode(v) { const s = String(v || '').toLowerCase(); return _MODES.includes(s) ? s : 'standard'; }
1159
+ function _normMode(v) {
1160
+ let s = '';
1161
+ try { s = String(v || '').toLowerCase(); } catch { s = ''; }
1162
+ return _MODES.includes(s) ? s : 'standard';
1163
+ }
1127
1164
  // 1.36.107: 재init 이 사용자의 선택을 되돌리지 않게 하는 두 술어. 우선순위는 **명시 플래그 > 기존 저장값 > 기본값**.
1128
1165
  // opts.mode 는 호출부에서 정규화하지 않고 원본을 넘겨야 한다 — 미리 _normMode 를 걸면 '미지정' 과 '--mode standard'
1129
1166
  // 가 구분되지 않아 명시적으로 standard 로 올리는 것과 그냥 재init 한 것이 같아진다.
@@ -1240,7 +1277,7 @@ leerness drift check . # 4 신호 + 4단계 레벨
1240
1277
  ## Step 3. 업무 분배 — sub-agent 매핑
1241
1278
  \`\`\`bash
1242
1279
  leerness agents list # ready CLI 확인
1243
- leerness agents quota # 한도 확인
1280
+ leerness agents quota # 공급자 상태 관측(잔여량은 검증된 공식 어댑터가 있을 때만)
1244
1281
  leerness agents dispatch "<task>" --to <id> # 작업 유형 추천 자동
1245
1282
  \`\`\`
1246
1283
  - 작업 유형별 기본 sub-agent 매핑 (leerness 의 **기본 관례**이며 측정된 성능·품질 순위가 아닙니다):
@@ -1413,7 +1450,7 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
1413
1450
  _files['.leerness/feature-contracts.md'] = fm('feature-contracts', ['before implementing/changing features'], ['feature IO/state/error changes'], `# Feature Contracts\n\n## Template\n- Feature:\n- Input:\n- Output:\n- States:\n- Errors:\n- Related files:\n- Test evidence ID:\n`, 'en');
1414
1451
  _files['.cursor/rules/leerness.mdc'] = `${MARK}\n---\nalwaysApply: true\n---\nFollow AGENTS.md and .leerness/context-routing.md.\nRun: \`leerness handoff .\` at session start.\nRun: \`leerness session close .\` at session end.\nPreserve Leerness protected files.\n`;
1415
1452
  _files['.github/copilot-instructions.md'] = `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .leerness/ 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`;
1416
- _files['.leerness/session-workflow.md'] = fm('session-workflow', ['session start', 'new user request arrives', 'before distributing complex work'], ['workflow step changes'], `# Session Workflow — 6 steps of AI harness engineering\n\n> **At the start of every session the main agent reads this document first and follows the 6 steps as written.**\n> Same flow regardless of round length/complexity — that is what prevents drift.\n\n## Step 1. Analyze the request + check the environment\n\`\`\`bash\nleerness handoff . # load context + automatic drift warning\nleerness drift check . # signals + 4-level verdict\n\`\`\`\n- Decompose the request (5W1H). If ambiguous, ask clarifying questions (except autonomous mode).\n- On drift critical, run \`leerness session close .\` or \`drift check --auto-fix\` first.\n\n## Step 2. Plan\n- 3+ steps → use TodoWrite or \`leerness plan add\`.\n- New capability → search existing assets first via \`leerness reuse-map\` / \`reuse find <query>\`.\n- Multiple modules → define an integration spec up front (e.g. TICK_SPEC.md).\n\n## Step 3. Distribute — sub-agent mapping\n\`\`\`bash\nleerness agents list # ready CLIs\nleerness agents quota # limits\nleerness agents dispatch "<task>" --to <id>\n\`\`\`\n- Best sub-agent by task type: text/translation/analysis → claude · deep code reasoning → codex · direct file edits → agy --yolo · security review → \`leerness review --persona security\`.\n- **Conflict-prevention rules (mandatory)**: give each sub-agent *the exact files only it may modify*; require mtime-verification reports (concurrent writes risk last-writer-wins); verify against the spec afterwards with \`leerness contract verify\`.\n\n## Step 4. Sub-agent work + individual self-verification\n- Each sub-agent reports only after passing its own module tests.\n- Report format: line counts, tests N/N PASS, issues found, mtime verification.\n\n## Step 5. Integrated verification\n\`\`\`bash\nleerness contract verify SPEC.md src/<mod>.js\nleerness verify-claim T-XXX --run-tests --strict-claims\nleerness review <file> --persona security,performance,ux\n\`\`\`\n- The main agent writes and runs its own integration scenario (independent verification).\n- Cross-check that sub-agent verification and main verification *agree*.\n\n## Step 6. Close + handoff + next-round suggestion\n\`\`\`bash\nleerness session close . # --suggest on by default\nleerness session close . --no-suggest\n\n# or separately:\nleerness skill suggest .\nleerness drift check .\nleerness audit . --fix\n\`\`\`\n\n## 🧠 Memory CRUD Quick Reference\n\n| Surface | CREATE | READ | DELETE | RESTORE |\n|---|---|---|---|---|\n| **tasks** | task add | task list --json | task drop | task update |\n| **decisions** | decision add | decision list --json | decision drop | memory restore decisions |\n| **lessons** | lesson save | lesson list [--tag] | lesson drop | memory restore lessons |\n| **plan** | plan add | plan list --json | plan remove | memory restore plan |\n| **rules** | rule add | rule list --json | rule remove | (rule pause/resume) |\n\n\`\`\`bash\nleerness memory status [--json]\nleerness memory archive list [--surface s]\nleerness memory restore <surface> <target>\n\`\`\`\n\n**Recovering a wrongly saved item**:\n1. \`memory archive list\` — list restore candidates\n2. \`memory restore decisions "PostgreSQL"\` — archive → active\n3. handoff automatically notes archive activity within 24h every session\n\n## Auto-recovery & security\n- A skipped session close triggers drift critical at the next session start — recover with \`drift check --auto-fix\` (runs session close automatically on critical).\n- handoff auto-recalls past lessons (by current task keywords) and auto-suggests installed skills.\n- handoff prints a 1–2 line security summary (.env ↔ .env.example sync + .gitignore secret gaps); a missing .env in .gitignore is 🚨 CRITICAL — \`LEERNESS_AUTO_SECURITY_FIX=1\` runs \`audit --fix\` automatically.\n- \`leerness health\` gives a one-line combined check (drift + security + skills + usage + tasks).\n\n---\n\n## Quick checklist (before the session ends)\n- [ ] This round's tasks are registered in plan/progress-tracker (or task sync)\n- [ ] Every done item has evidence attached (verify-claim PASS)\n- [ ] contract verify PASS when sub-agents were used\n- [ ] drift score ≤ 30 — \`leerness drift check\`\n- [ ] session close was run\n- [ ] \`leerness health\` overall check\n- [ ] If .env is used: .gitignore secret patterns OK + .env.example synced\n- [ ] On security critical: \`LEERNESS_AUTO_SECURITY_FIX=1\` or \`audit --fix\`\n\n## Anti-patterns (drift signals)\n- ⚠ "work is done, just report and stop" → missed session close → drift critical next session\n- ⚠ "only TodoWrite updated, leerness unused" → \`task sync --from\` or \`task add\` is mandatory\n- ⚠ distributing to sub-agents without explicit file paths → concurrent-write conflicts\n- ⚠ self-reported "tests ran, PASS" only → verify-claim --run-tests not executed\n- ⚠ skipping contract verify → spec-mismatch bugs reach the user\n`, 'en');
1453
+ _files['.leerness/session-workflow.md'] = fm('session-workflow', ['session start', 'new user request arrives', 'before distributing complex work'], ['workflow step changes'], `# Session Workflow — 6 steps of AI harness engineering\n\n> **At the start of every session the main agent reads this document first and follows the 6 steps as written.**\n> Same flow regardless of round length/complexity — that is what prevents drift.\n\n## Step 1. Analyze the request + check the environment\n\`\`\`bash\nleerness handoff . # load context + automatic drift warning\nleerness drift check . # signals + 4-level verdict\n\`\`\`\n- Decompose the request (5W1H). If ambiguous, ask clarifying questions (except autonomous mode).\n- On drift critical, run \`leerness session close .\` or \`drift check --auto-fix\` first.\n\n## Step 2. Plan\n- 3+ steps → use TodoWrite or \`leerness plan add\`.\n- New capability → search existing assets first via \`leerness reuse-map\` / \`reuse find <query>\`.\n- Multiple modules → define an integration spec up front (e.g. TICK_SPEC.md).\n\n## Step 3. Distribute — sub-agent mapping\n\`\`\`bash\nleerness agents list # ready CLIs\nleerness agents quota # provider status; remaining capacity only via a verified official adapter\nleerness agents dispatch "<task>" --to <id>\n\`\`\`\n- Best sub-agent by task type: text/translation/analysis → claude · deep code reasoning → codex · direct file edits → agy --yolo · security review → \`leerness review --persona security\`.\n- **Conflict-prevention rules (mandatory)**: give each sub-agent *the exact files only it may modify*; require mtime-verification reports (concurrent writes risk last-writer-wins); verify against the spec afterwards with \`leerness contract verify\`.\n\n## Step 4. Sub-agent work + individual self-verification\n- Each sub-agent reports only after passing its own module tests.\n- Report format: line counts, tests N/N PASS, issues found, mtime verification.\n\n## Step 5. Integrated verification\n\`\`\`bash\nleerness contract verify SPEC.md src/<mod>.js\nleerness verify-claim T-XXX --run-tests --strict-claims\nleerness review <file> --persona security,performance,ux\n\`\`\`\n- The main agent writes and runs its own integration scenario (independent verification).\n- Cross-check that sub-agent verification and main verification *agree*.\n\n## Step 6. Close + handoff + next-round suggestion\n\`\`\`bash\nleerness session close . # --suggest on by default\nleerness session close . --no-suggest\n\n# or separately:\nleerness skill suggest .\nleerness drift check .\nleerness audit . --fix\n\`\`\`\n\n## 🧠 Memory CRUD Quick Reference\n\n| Surface | CREATE | READ | DELETE | RESTORE |\n|---|---|---|---|---|\n| **tasks** | task add | task list --json | task drop | task update |\n| **decisions** | decision add | decision list --json | decision drop | memory restore decisions |\n| **lessons** | lesson save | lesson list [--tag] | lesson drop | memory restore lessons |\n| **plan** | plan add | plan list --json | plan remove | memory restore plan |\n| **rules** | rule add | rule list --json | rule remove | (rule pause/resume) |\n\n\`\`\`bash\nleerness memory status [--json]\nleerness memory archive list [--surface s]\nleerness memory restore <surface> <target>\n\`\`\`\n\n**Recovering a wrongly saved item**:\n1. \`memory archive list\` — list restore candidates\n2. \`memory restore decisions "PostgreSQL"\` — archive → active\n3. handoff automatically notes archive activity within 24h every session\n\n## Auto-recovery & security\n- A skipped session close triggers drift critical at the next session start — recover with \`drift check --auto-fix\` (runs session close automatically on critical).\n- handoff auto-recalls past lessons (by current task keywords) and auto-suggests installed skills.\n- handoff prints a 1–2 line security summary (.env ↔ .env.example sync + .gitignore secret gaps); a missing .env in .gitignore is 🚨 CRITICAL — \`LEERNESS_AUTO_SECURITY_FIX=1\` runs \`audit --fix\` automatically.\n- \`leerness health\` gives a one-line combined check (drift + security + skills + usage + tasks).\n\n---\n\n## Quick checklist (before the session ends)\n- [ ] This round's tasks are registered in plan/progress-tracker (or task sync)\n- [ ] Every done item has evidence attached (verify-claim PASS)\n- [ ] contract verify PASS when sub-agents were used\n- [ ] drift score ≤ 30 — \`leerness drift check\`\n- [ ] session close was run\n- [ ] \`leerness health\` overall check\n- [ ] If .env is used: .gitignore secret patterns OK + .env.example synced\n- [ ] On security critical: \`LEERNESS_AUTO_SECURITY_FIX=1\` or \`audit --fix\`\n\n## Anti-patterns (drift signals)\n- ⚠ "work is done, just report and stop" → missed session close → drift critical next session\n- ⚠ "only TodoWrite updated, leerness unused" → \`task sync --from\` or \`task add\` is mandatory\n- ⚠ distributing to sub-agents without explicit file paths → concurrent-write conflicts\n- ⚠ self-reported "tests ran, PASS" only → verify-claim --run-tests not executed\n- ⚠ skipping contract verify → spec-mismatch bugs reach the user\n`, 'en');
1417
1454
  }
1418
1455
  // T-0136: handoff is a tracked-read boundary. The localized templates predate that contract,
1419
1456
  // so normalize the one stale sentence after language selection from a single place.
@@ -4328,8 +4365,8 @@ function _selfTestCases() {
4328
4365
  && JSON.stringify(codexBench.args) === JSON.stringify(['exec', '--skip-git-repo-check', 'probe']);
4329
4366
  // (4) 1.36.29 (사용자 보고): multi 도 브리프 접두 — --execute 실 spawn(_cliChat) + 명령 목록/JSON 3지점 전부 briefTask.
4330
4367
  const multiWired = agentsSrc.includes('_cliChat(root, def.id, briefTask,')
4331
- && agentsSrc.includes('_dispatchCommand(x.def.id, briefTask, writeMode)')
4332
- && agentsSrc.includes('_dispatchCommand(def.id, briefTask, writeMode)');
4368
+ && agentsSrc.includes('_dispatchCommand(x.def.id, briefTask, writeMode, null, x.def)')
4369
+ && agentsSrc.includes('_dispatchCommand(def.id, briefTask, writeMode, null, def)');
4333
4370
  return briefOk && wired && stdinFixed && multiWired;
4334
4371
  } },
4335
4372
  { name: '19th GPT5.5평가 (UR-0013): _parseTestStdout 러너 인식(jest/tap/spec/비율) + declared-pass 부풀림 3경로 게이팅 + json ok/reasons (1.35.7)', run: () => {
@@ -5118,7 +5155,7 @@ function _selfTestCases() {
5118
5155
  { name: 'UR-0025 심층(Codex 위임·검증): anti-laziness catalog→lib/catalogs(OPTIMISM_PATTERNS) + optimism 순수로직→pure-utils 분리 (1.9.336)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = Array.isArray(c.OPTIMISM_PATTERNS) && c.OPTIMISM_PATTERNS.length === 10 && c.OPTIMISM_PATTERNS[0].kind === 'API'; const ev = 'API 호출 완료, POST /users'; const sus = m._detectOptimism(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const conf = m._computeConfidence(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const work = sus.some(s => s.kind === 'API' && s.severity === 'high') && conf < 0.5 && m._computeConfidence(c.OPTIMISM_PATTERNS, '정리함', 'x') === 1 && m._detectOptimism(null, ev, 'x').length === 0 && m._extractUrlClaims('POST /a').length === 1 && m._verifyUrlClaim({ path: '/a' }, 'has /a') === true; const src = read(__filename); const moved = OPTIMISM_PATTERNS === c.OPTIMISM_PATTERNS && _puDetectOptimism === m._detectOptimism && !/const OPTIMISM_PATTERNS = \[/.test(src) && !/function _extractUrlClaims\(/.test(src); return catOk && work && moved; } },
5119
5156
  { name: 'UR-0025 심층: persona catalog→lib/catalogs(BUILT_IN_PERSONAS) + _personaSummaries→pure-utils 분리 (1.9.337)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c.BUILT_IN_PERSONAS && Object.keys(c.BUILT_IN_PERSONAS).length === 5 && c.BUILT_IN_PERSONAS.security && typeof c.BUILT_IN_PERSONAS.security.body === 'string'; const sm = m._personaSummaries(c.BUILT_IN_PERSONAS); const work = Array.isArray(sm) && sm.length === 5 && sm[0].id === 'security' && sm[0].body === undefined && typeof sm[0].name === 'string' && m._personaSummaries(null).length === 0; const src = read(__filename); const moved = BUILT_IN_PERSONAS === c.BUILT_IN_PERSONAS && _personaSummaries === m._personaSummaries && !/const BUILT_IN_PERSONAS = \{/.test(src); return catOk && work && moved; } },
5120
5157
  { name: 'UR-0025 심층: i18n STRINGS catalog→lib/catalogs + _translate→pure-utils 분리 (1.9.338)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c.STRINGS && typeof c.STRINGS['common.ready'] === 'object' && c.STRINGS['common.ready'].en === 'Ready'; const work = m._translate(c.STRINGS, 'common.ready', 'en') === 'Ready' && m._translate(c.STRINGS, 'common.ready', 'ko') === '준비 완료' && m._translate(c.STRINGS, 'no.such.key', 'en') === 'no.such.key' && m._translate(null, 'x', 'ko') === 'x' && m._translate({ k: { ko: '케이' } }, 'k', 'en') === '케이'; const src = read(__filename); const moved = STRINGS === c.STRINGS && _translate === m._translate && !/const STRINGS = \{/.test(src); return catOk && work && moved; } },
5121
- { name: 'UR-0053: decisions canonical JSON 레이어(_loadDecisions/_saveDecisions/decisionsJsonPath) + pure 파서/렌더 round-trip (1.9.339)', run: () => { const m = require('../lib/pure-utils'); const md = '# Decisions\n\n### 2026-06-05 — A\n- Decision: a\n- Reason: r\n- Alternatives: alt\n- Impact: imp\n\n### 2026-06-04 — B\n- Decision: b\n- Alternatives:\n'; const objs = m._decisionsFromMd(md); const parseOk = objs.length === 2 && objs[0].alternatives === 'alt' && objs[0].impact === 'imp' && objs[1].alternatives === null && objs[1].title === 'B'; const rt = m._decisionsFromMd(m._renderDecisionsMd(objs)); const rtOk = JSON.stringify(rt) === JSON.stringify(objs); const tplOk = m._decisionsFromMd(m._renderDecisionsMd([])).length === 0; const layerOk = typeof _loadDecisions === 'function' && typeof _saveDecisions === 'function' && typeof decisionsJsonPath === 'function' && _decisionsFromMd === m._decisionsFromMd; return parseOk && rtOk && tplOk && layerOk; } },
5158
+ { name: 'UR-0053: decisions canonical JSON 레이어(_loadDecisions/_saveDecisions/decisionsJsonPath) + pure 파서/렌더 round-trip (1.9.339)', run: () => { const m = require('../lib/pure-utils'); const md = '# Decisions\n\n### 2026-06-05 — A\n- Decision: a\n- Reason: r\n- Alternatives: alt\n- Impact: imp\n\n### 2026-06-04 — B\n- Decision: b\n- Alternatives:\n'; const objs = m._decisionsFromMd(md); const parseOk = objs.length === 2 && objs[0].alternatives === 'alt' && objs[0].impact === 'imp' && objs[1].alternatives === null && objs[1].title === 'B'; const rt = m._decisionsFromMd(m._renderDecisionsMd(objs)); const rtOk = JSON.stringify(rt) === JSON.stringify(objs); const tplOk = m._decisionsFromMd(m._renderDecisionsMd([])).length === 0; const noTrailing = !/[ \t]+$/m.test(m._renderDecisionsMd(objs)); const layerOk = typeof _loadDecisions === 'function' && typeof _saveDecisions === 'function' && typeof decisionsJsonPath === 'function' && _decisionsFromMd === m._decisionsFromMd; return parseOk && rtOk && tplOk && noTrailing && layerOk; } },
5122
5159
  { name: 'UR-0058: lessons canonical JSON 레이어(_loadLessons/_saveLessons/lessonsJsonPath) + pure 파서/렌더 round-trip', run: () => { const m = require('../lib/pure-utils'); const objs = [{ date: '2026-06-05', text: 'A', tag: 't' }, { date: '2026-06-04', text: 'B', tag: null }]; const rt = m._parseLessonEntries(m._renderLessonsMd(objs)); const rtOk = JSON.stringify(rt) === JSON.stringify(objs); const tplOk = m._parseLessonEntries(m._renderLessonsMd([])).length === 0; const layerOk = typeof _loadLessons === 'function' && typeof _saveLessons === 'function' && typeof lessonsJsonPath === 'function' && _renderLessonsMd === m._renderLessonsMd; return rtOk && tplOk && layerOk; } },
5123
5160
  { name: 'UR-0025 심층: BUILTIN_CATALOG→lib/catalogs + _withBuiltinSource→pure-utils 분리 (1.9.341)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c.BUILTIN_CATALOG && Object.keys(c.BUILTIN_CATALOG).length === 2 && c.BUILTIN_CATALOG['feature-implementation'] && c.BUILTIN_CATALOG['project-roadmap-generator'] && Object.values(c.BUILTIN_CATALOG).every(v => v.verification !== 'passed'); const out = m._withBuiltinSource(c.BUILTIN_CATALOG); const work = Object.keys(out).length === 2 && Object.values(out).every(v => v._source === 'builtin') && out['feature-implementation'].version === '1.0.0' && Array.isArray(out['feature-implementation'].capabilities) && Object.keys(m._withBuiltinSource(null)).length === 0; const src = read(__filename); const moved = BUILTIN_CATALOG === c.BUILTIN_CATALOG && _withBuiltinSource === m._withBuiltinSource && !/const BUILTIN_CATALOG = \{/.test(src); return catOk && work && moved; } },
5124
5161
  { name: 'UR-0025 심층: ROADMAP_STATUS_LABEL/COLOR→lib/catalogs 분리 (1.9.342)', run: () => { const c = require('../lib/catalogs'); const lblOk = c.ROADMAP_STATUS_LABEL && Object.keys(c.ROADMAP_STATUS_LABEL).length === 11 && c.ROADMAP_STATUS_LABEL.done === '완료' && c.ROADMAP_STATUS_LABEL.blocked === '오류'; const colOk = c.ROADMAP_STATUS_COLOR && Object.keys(c.ROADMAP_STATUS_COLOR).length === 11 && c.ROADMAP_STATUS_COLOR.done === '#16a34a' && c.ROADMAP_STATUS_COLOR.skill === '#8b5cf6'; const src = read(__filename); const moved = ROADMAP_STATUS_LABEL === c.ROADMAP_STATUS_LABEL && ROADMAP_STATUS_COLOR === c.ROADMAP_STATUS_COLOR && !/const ROADMAP_STATUS_LABEL = \{/.test(src); return lblOk && colOk && moved; } },
@@ -5600,9 +5637,12 @@ function _selfTestCases() {
5600
5637
  const src = read(__filename);
5601
5638
  return guard(hSrc) && guard(dSrc) && guard(src); // status(bin) 도 동일 가드
5602
5639
  } },
5603
- { name: '11th 외부평가 Codex P2 (UR-0137): agents dispatch task 에 flag 값(--to/--model 등) 흡수 차단 (1.9.435)', run: () => {
5604
- const modSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'agents.js'));
5605
- return modSrc.includes('const _taskArg = () =>') && modSrc.includes("const consumed = new Set([arg('--to', null)") && modSrc.includes('if (consumed.has(a)) continue;');
5640
+ { name: '11th 외부평가 Codex P2 (UR-0137): agents task parser 가 flag 값과 사용자 본문을 분리 (1.9.435/T-0165)', run: () => {
5641
+ const parse = require('../lib/agents')._parseAgentPositional;
5642
+ return parse(['fix auth', '--to', 'codex', '--model', 'm1']) === 'fix auth'
5643
+ && parse(['--to', 'codex', 'fix auth', '--role', 'coder']) === 'fix auth'
5644
+ && parse(['provider', 'fix auth', '--approved-by', 'owner'], 1) === 'fix auth'
5645
+ && parse(['--to=codex', '--json', '--', '-literal task']) === '-literal task';
5606
5646
  } },
5607
5647
  { name: '11th 외부평가 Opus P3 (UR-0139): 시크릿 all-X/0 placeholder FP — 동일문자 8+연속은 prefix 무관 더미 (1.9.436)', run: () => {
5608
5648
  const m = require('../lib/pure-utils');
@@ -9973,7 +10013,8 @@ function _p0015ModeOk() {
9973
10013
  const normOk = _normMode('minimal') === 'minimal' && _normMode('standard') === 'standard'
9974
10014
  && _normMode('') === 'standard' && _normMode('MINIMAL') === 'minimal'
9975
10015
  && _normMode('banana') === 'standard' && _normMode(null) === 'standard'
9976
- && _normMode('strict') === 'standard' && _MODES.length === 2;
10016
+ && _normMode('strict') === 'standard' && _normMode({ toString: null }) === 'standard'
10017
+ && _MODES.length === 2;
9977
10018
  // ② 생성물이 실제로 갈린다 — 이름만 바뀌면 이 기능은 무의미하다
9978
10019
  const full = coreFiles('.', 'ko', [], {});
9979
10020
  const min = coreFiles('.', 'ko', [], { mode: 'minimal' });
@@ -11442,8 +11483,8 @@ function commandsCmd(root) {
11442
11483
  { cmd: 'session close [path] [--json] [--auto-apply-delivered]', desc: '세션 마감 + 9 카테고리 + 자동 통합', descEn: 'close the session + 9 categories + auto integration' },
11443
11484
  { cmd: 'resume [path]', desc: 'auto-resume-plan 적용 (1.9.203)', descEn: 'apply the auto-resume plan (1.9.203)' },
11444
11485
  { cmd: 'route <task-type>', desc: '작업 유형 분류 (11종)', descEn: 'classify the task type (11 kinds)' },
11445
- { cmd: 'agents list|check|quota|dispatch|multi|recommend', desc: '외부 AI CLI 오케스트레이션 (dispatch --role 모델 라우팅 1.9.270)', descEn: 'orchestrate external AI CLIs (dispatch --role model routing 1.9.270)' },
11446
- { cmd: 'roles list|set|unset|catalog|suggest|verify', desc: '모델별 역할 부여 (코딩/검수/지휘/디자인/디버그/설계/분배) — 1.9.270', descEn: 'assign roles per model (coding/review/command/design/debug/architecture/dispatch) — 1.9.270' },
11486
+ { cmd: 'agents list|check|quota|resolve|fallback|record|history|dispatch|multi|recommend', desc: '외부 AI CLI 오케스트레이션 + 역할 가용성·폴백 선택·실행 provenance', descEn: 'orchestrate external AI CLIs + role availability, fallback selection, and execution provenance' },
11487
+ { cmd: 'roles list|set|unset|catalog|suggest|verify|validate', desc: '모델별 역할 부여 + fail-closed 저장소 검증 — 1.9.270/T-0171', descEn: 'assign roles and validate the role store fail-closed — 1.9.270/T-0171' },
11447
11488
  { cmd: 'capabilities [--json]', desc: '권한·보안 표면 공개 (무엇을 하는지 + opt-out + 주의 명령) — 1.9.272', descEn: 'disclose the permission/security surface (what it does + opt-outs + commands to watch) — 1.9.272' },
11448
11489
  { cmd: 'state show|start|record|verify|handoff', desc: '.leerness/ JSON 상태 substrate (에이전트 간 인수인계 표준) — 1.9.278', descEn: '.leerness/ JSON state substrate (cross-agent handoff standard) — 1.9.278' },
11449
11490
  { cmd: 'adapter <tool>|list [--dry-run]', desc: '도구별 지침/.mcp.json 선택 생성 (claude/cursor/codex/goose/...) — 1.9.280', descEn: 'generate per-tool instructions/.mcp.json selectively (claude/cursor/codex/goose/...) — 1.9.280' },
@@ -11462,7 +11503,7 @@ function commandsCmd(root) {
11462
11503
  { cmd: 'referee add|verify|list|show|drop', desc: '검증기 캘리브레이션 — 신뢰 전에 탐지력 증명(known-good 통과 + 망가뜨린 known-bad 를 기대 사유로 거부). verify-claim --referee / gate 게이팅 — 1.36.80 P-0001', descEn: 'verifier calibration — prove detection power before trusting it (passes known-good, rejects a broken known-bad for the expected reason). Gates verify-claim --referee / gate — 1.36.80 P-0001' },
11463
11504
  { cmd: 'preview add|list|show|approve|revise|mockup|verify', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--target <파일>] [--force]: 프로젝트의 실제 토큰·컴포넌트·대상 화면 클래스를 심은 시안 · approve 시 그 시안이 계약으로 고정됨 · verify <P-ID> --files a,b: 구현이 계약을 지켰는지 존재 대조(시각적 일치는 보증하지 않음) — 1.36.51 UR-0061 · 1.36.75 UR-0066 · 1.36.99/1.36.100 P-0012', descEn: 'preview-approval workflow for new features — contract: no code before approve. mockup <P-ID> [--target <file>] [--force]: a draft seeded with the project\'s real tokens/components/target-screen classes · approve freezes that draft as the contract · verify <P-ID> --files a,b: existence check that the implementation kept the contract (visual match is NOT guaranteed) — 1.36.51 UR-0061 · 1.36.75 UR-0066 · 1.36.99/1.36.100 P-0012' },
11464
11505
  { cmd: 'dashboard [path] [--port N] [--timeout SEC] [--json]', desc: '.leerness 읽기 전용 대시보드 — 토글·task·마일스톤·미리보기·라우팅기록·bugfix probe 를 localhost 에서 열람. 설정 변경·게이트 판정은 하지 않는다(CLI 가 판정). --json 은 서버 없이 같은 스냅샷 출력. 정적 단일파일 뷰(leerness.html)는 leerness graph --html 로 그대로 — 1.36.90 P-0006', descEn: 'read-only .leerness dashboard — browse toggles/tasks/milestones/previews/routing history/bugfix probes on localhost. It never changes settings or makes gate verdicts (the CLI does). --json prints the same snapshot without a server. The static single-file view (leerness.html) is still leerness graph --html — 1.36.90 P-0006' },
11465
- { cmd: 'agents route "<작업>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<승인자>"] [--log]', cmdEn: 'agents route "<task>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<approver>"] [--log]', desc: '작업 난이도 판정(결정적 규칙) → 추상 역할(architect/commander/coder/reviewer) 배치 제안. 모델명 하드코딩 없이 `roles set` 매핑을 쓰고, 실행은 명시 확인 필요. 토글 기본 OFF: leerness toggle set difficulty-routing on — 1.36.89 P-0007', descEn: 'judge task difficulty (deterministic rules) → propose an abstract-role assignment (architect/commander/coder/reviewer). Uses your `roles set` mapping instead of hardcoded model names, and execution needs explicit confirmation. Toggle is OFF by default: leerness toggle set difficulty-routing on — 1.36.89 P-0007' },
11506
+ { cmd: 'agents route "<작업>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<승인자>"] [--reason "<사유>"] [--log]', cmdEn: 'agents route "<task>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<approver>"] [--reason "<reason>"] [--log]', desc: '작업 난이도 판정(결정적 규칙) → 추상 역할 배치 제안. 감지된 고위험 등급 하향은 승인자+사유가 모두 필요하고, 실행은 명시 확인 필요. 토글 기본 OFF: leerness toggle set difficulty-routing on — 1.36.89 P-0007', descEn: 'judge task difficulty (deterministic rules) → propose abstract-role assignments. Lowering detected high risk requires both approver and reason, and execution needs explicit confirmation. Toggle is OFF by default: leerness toggle set difficulty-routing on — 1.36.89 P-0007' },
11466
11507
  { cmd: 'bugfix start|receipt|drop|list', desc: 'bugfix 완료 영수증 — 수정 전 재현 probe 를 등록(2회 연속 실패해야 등록)하고, done 전이에서 통과 + 근본원인/형제범위 영수증을 요구. 토글 기본 OFF: leerness toggle set bugfix-receipt on — 1.36.87 P-0005', descEn: 'bugfix completion receipt — register a reproduction probe before the fix (it must fail twice in a row to register), then require it to pass at the done transition plus a root-cause/sibling-scope receipt. Toggle is OFF by default: leerness toggle set bugfix-receipt on — 1.36.87 P-0005' },
11467
11508
  { cmd: 'review <file> --persona <ids>', desc: '페르소나 리뷰 (1.9.29)', descEn: 'persona-based review (1.9.29)' },
11468
11509
  { cmd: 'brainstorm "<topic>" [--include-code]', desc: '워크스페이스 회수 + 코드 grep', descEn: 'recall from the workspace + grep the code' }
@@ -11489,6 +11530,7 @@ function commandsCmd(root) {
11489
11530
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트', descEn: 'self-update' },
11490
11531
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)', descEn: 'adaptive wakeup interval (1.9.210)' },
11491
11532
  { cmd: 'sessions [path] [--json]', desc: '이 프로젝트에 남은 **다른 AI 세션 기록** (1.36.129, P-0016 P1) — 기록만 보여 주고 프로세스 상태는 판정하지 않는다', descEn: 'session records left by other AI sessions in this project (1.36.129, P-0016 P1) — records only; process state is never judged' },
11533
+ { cmd: 'lease acquire|release|list|check [file|lease-id] [--session KEY] [--ttl SEC] [--json]', desc: '명시적 exact-file TTL lease — 동기식 충돌만 반환하는 opt-in 협업 힌트(외부 편집기 강제 잠금·ambient 경고 없음)', descEn: 'explicit exact-file TTL lease — opt-in synchronous coordination hint (no external-editor lock and no ambient warnings)' },
11492
11534
  { cmd: 'workspace-dir get|guide', desc: `워크스페이스 디렉토리 확인 — ${CANONICAL_WORKSPACE_DIR} canonical + 기존 ${LEGACY_WORKSPACE_DIR} 안전 마이그레이션 (T-0107)`, descEn: `show the workspace directory — canonical ${CANONICAL_WORKSPACE_DIR} + safe legacy ${LEGACY_WORKSPACE_DIR} migration (T-0107)` },
11493
11535
  { cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)', descEn: 'detect a parent leerness above + gated asset adoption (1.30.2~3)' },
11494
11536
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)', descEn: 'infer intent + scope (1.9.213)' },
@@ -12136,6 +12178,9 @@ const _OBSERVATION_ONLY_SUBCOMMANDS = new Map([
12136
12178
  ['release', new Set(['cadence'])],
12137
12179
  ['auto-update', new Set(['status'])],
12138
12180
  ['preview', new Set(['list', 'show'])],
12181
+ ['lease', new Set(['list', 'check'])],
12182
+ ['roles', new Set(['validate'])],
12183
+ ['role', new Set(['validate'])],
12139
12184
  ]);
12140
12185
  function _cliMutationClass(args, cmd) {
12141
12186
  if (cmd === 'mcp') return 'observation-only'; // server startup must not mutate its incidental cwd
@@ -12144,6 +12189,7 @@ function _cliMutationClass(args, cmd) {
12144
12189
  // Classify the alias before auto-migration/telemetry so adding --json/--path
12145
12190
  // cannot turn a read into a write merely because no child token exists.
12146
12191
  if (cmd === 'preview' && (!args || !args[1] || String(args[1]).startsWith('-'))) return 'observation-only';
12192
+ if (cmd === 'lease' && (!args || !args[1] || String(args[1]).startsWith('-'))) return 'observation-only';
12147
12193
  const children = _OBSERVATION_ONLY_SUBCOMMANDS.get(cmd);
12148
12194
  return children && args && children.has(args[1]) ? 'observation-only' : 'stateful';
12149
12195
  }
@@ -13931,6 +13977,122 @@ function _agentReminderSignal(root) {
13931
13977
  String(s).replace(new RegExp('[\u0000-\u001f\u007f]', 'g'), ' ').replace(/^[=\-]{3,}/, ''), 200).trim();
13932
13978
  return { path: rel(root, p), lines: lines.length, truncated, head: lines.slice(0, 6).map(l => _clean(l).slice(0, 200)).filter(Boolean) };
13933
13979
  }
13980
+ // Exact-file lease is deliberately separate from `sessions scope`. The discarded ambient
13981
+ // scope advisory exceeded its declared noise ceiling; this command acts only after an
13982
+ // explicit acquire/check/release request for one exact file and never injects warnings
13983
+ // into handoff, status, or dashboards.
13984
+ function fileLeaseCmd(root, action, subject, extraOperands) {
13985
+ const L = require('../lib/file-leases');
13986
+ const SP = require('../lib/session-presence');
13987
+ root = absRoot(root || process.cwd());
13988
+ action = action == null ? 'list' : action;
13989
+ const ui = _uiLang(root);
13990
+ const t = (ko, en) => ui === 'en' ? en : ko;
13991
+ const json = has('--json');
13992
+ const safeArg = (value) => _lineSafe(String(value == null ? '' : value)).slice(0, 120);
13993
+ const common = ['--path', '--json', '--language', '--no-stale-check', '--no-banner'];
13994
+ const allowed = {
13995
+ acquire: ['--session', '--ttl', '--note'],
13996
+ release: ['--session'],
13997
+ list: ['--session', '--all'],
13998
+ check: ['--session'],
13999
+ }[action];
14000
+ if (!allowed) {
14001
+ const badAction = safeArg(action || '(missing)');
14002
+ const payload = { ok: false, code: 'unknown_subcommand', error: `Unknown lease subcommand: ${badAction}`, choices: ['acquire', 'release', 'list', 'check'] };
14003
+ if (json) log(JSON.stringify(payload, null, 2));
14004
+ else fail(t(`알 수 없는 lease 하위명령: ${badAction} (가능: acquire, release, list, check)`, `unknown lease subcommand: ${badAction} (choices: acquire, release, list, check)`));
14005
+ process.exitCode = 1;
14006
+ return payload;
14007
+ }
14008
+ if (!_rejectUnknownFlags(allowed, 'lease acquire|release|list|check', { globals: common })) return;
14009
+ if (Array.isArray(extraOperands) && extraOperands.length > 0) {
14010
+ const stray = safeArg(extraOperands[0]);
14011
+ const payload = { ok: false, code: 'too_many_arguments', error: `Unexpected lease argument: ${stray}` };
14012
+ if (json) log(JSON.stringify(payload, null, 2));
14013
+ else fail(t(`불필요한 lease 인자: ${stray}`, `unexpected lease argument: ${stray}`));
14014
+ process.exitCode = 1;
14015
+ return payload;
14016
+ }
14017
+ if ((action === 'list' && subject) || ((action === 'acquire' || action === 'release' || action === 'check') && !subject)) {
14018
+ const code = action === 'list' ? 'too_many_arguments' : (action === 'release' ? 'missing_lease_id' : 'missing_file');
14019
+ const payload = { ok: false, code, error: action === 'list' ? `Unexpected lease argument: ${safeArg(subject)}` : `lease ${action} requires ${action === 'release' ? 'a lease ID' : 'a file path'}.` };
14020
+ if (json) log(JSON.stringify(payload, null, 2));
14021
+ else fail(t(`lease ${action} 인자 오류`, `invalid lease ${action} arguments`));
14022
+ process.exitCode = 1;
14023
+ return payload;
14024
+ }
14025
+
14026
+ const sessionKey = (required) => {
14027
+ const explicit = arg('--session', null);
14028
+ if (explicit != null) return L.normalizeSessionKey(explicit);
14029
+ if (!required) return null;
14030
+ if (typeof SP.invalidExplicitKey === 'function' && SP.invalidExplicitKey(process.env) != null) {
14031
+ throw new L.LeaseError('invalid_session_key', 'LEERNESS_SESSION_ID is explicitly set but invalid; refusing ambient fallback.');
14032
+ }
14033
+ return L.normalizeSessionKey(SP.deriveSessionKey(process.env));
14034
+ };
14035
+ const errorPayload = (error) => {
14036
+ if (error instanceof L.LeaseError) {
14037
+ return { ok: false, code: error.code, error: error.message, ...(error.details ? { details: error.details } : {}) };
14038
+ }
14039
+ return { ok: false, code: 'lease_internal_error', error: String(error && error.message || error || 'unknown error').slice(0, 300) };
14040
+ };
14041
+ try {
14042
+ let result;
14043
+ if (action === 'acquire') {
14044
+ if (!subject) throw new L.LeaseError('missing_file', 'lease acquire requires a file path.');
14045
+ result = L.acquire(root, subject, sessionKey(true), {
14046
+ ttlSeconds: arg('--ttl', null), note: arg('--note', null), withLock: _withLock,
14047
+ });
14048
+ } else if (action === 'release') {
14049
+ if (!subject) throw new L.LeaseError('missing_lease_id', 'lease release requires a lease ID.');
14050
+ result = L.release(root, subject, sessionKey(true), { withLock: _withLock });
14051
+ } else if (action === 'check') {
14052
+ if (!subject) throw new L.LeaseError('missing_file', 'lease check requires a file path.');
14053
+ result = L.check(root, subject, { sessionKey: sessionKey(false) });
14054
+ } else {
14055
+ result = L.list(root, { sessionKey: sessionKey(false), all: has('--all') });
14056
+ }
14057
+
14058
+ if (json) {
14059
+ log(JSON.stringify(result, null, 2));
14060
+ } else if (action === 'acquire') {
14061
+ log(t(`✓ 파일 lease ${result.action === 'renewed' ? '갱신' : '획득'}: ${result.lease.file}`, `✓ file lease ${result.action}: ${result.lease.file}`));
14062
+ log(` ${result.lease.leaseId} · ${result.lease.sessionKey} · ${result.ttlSeconds}s · ${result.lease.expiresAt}`);
14063
+ log(t(' Leerness-aware 세션 사이의 협업 힌트이며 외부 편집기를 강제로 잠그지 않습니다.', ' Coordination hint between Leerness-aware sessions; external editors are not locked.'));
14064
+ } else if (action === 'release') {
14065
+ log(t(`✓ 파일 lease 해제: ${result.lease.file}`, `✓ file lease released: ${result.lease.file}`));
14066
+ log(` ${result.lease.leaseId} · ${result.lease.sessionKey}`);
14067
+ } else if (action === 'check') {
14068
+ if (result.available && result.ownedBySession) log(t(`✓ 사용 가능: ${result.target.file} (이 세션 소유)`, `✓ available: ${result.target.file} (owned by this session)`));
14069
+ else if (result.available) log(t(`✓ 사용 가능: ${result.target.file}`, `✓ available: ${result.target.file}`));
14070
+ else {
14071
+ fail(t(`다른 세션 lease: ${result.target.file}`, `leased by another session: ${result.target.file}`));
14072
+ log(` ${result.lease.leaseId} · ${result.lease.sessionKey} · ${result.lease.expiresAt}`);
14073
+ }
14074
+ } else {
14075
+ log(t('# 파일 lease', '# file leases'));
14076
+ log(t(`저장 ${result.totalStored} · 활성 ${result.active} · 만료 ${result.expired} · 표시 ${result.shown}`, `stored ${result.totalStored} · active ${result.active} · expired ${result.expired} · shown ${result.shown}`));
14077
+ for (const lease of result.leases) log(` ${lease.status === 'active' ? '●' : '○'} ${lease.file} · ${lease.sessionKey} · ${lease.leaseId} · ${lease.expiresAt}`);
14078
+ if (!result.leases.length) log(t(' lease 없음.', ' no leases.'));
14079
+ }
14080
+ if (action === 'check' && result.available === false) process.exitCode = 1;
14081
+ return result;
14082
+ } catch (error) {
14083
+ const payload = errorPayload(error);
14084
+ if (json) log(JSON.stringify(payload, null, 2));
14085
+ else if (payload.code === 'lease_conflict' && payload.details && payload.details.conflict) {
14086
+ const c = payload.details.conflict;
14087
+ fail(t(`파일 lease 충돌: ${c.file} · 소유 세션 ${c.sessionKey} · 만료 ${c.expiresAt}`, `file lease conflict: ${c.file} · owner ${c.sessionKey} · expires ${c.expiresAt}`));
14088
+ } else {
14089
+ fail(t(`lease 오류(${payload.code}): ${payload.error}`, `lease error (${payload.code}): ${payload.error}`));
14090
+ }
14091
+ process.exitCode = 1;
14092
+ return payload;
14093
+ }
14094
+ }
14095
+
13934
14096
  // 1.36.129 (UR-0068 / P-0016 P1): `leerness sessions` — 이 프로젝트에 남은 세션 **기록**을 보여 준다.
13935
14097
  // ⚠ 문구 원칙: 검증하지 않은 것을 주장하지 않는다. "활성/active" 라는 말을 쓰지 않고,
13936
14098
  // close 기록이 없다는 사실만 말한다. 그리고 **못 보는 것을 항상 함께 적는다** —
@@ -15256,7 +15418,7 @@ function _rejectUnknownFlags(allowed, usageHint, options = {}) {
15256
15418
  const dym = (u) => { const c = [...all].find(k => u.startsWith(k) || k.startsWith(u)); return c ? ` — 혹시 ${c}?` : ''; };
15257
15419
  const detail = unknown.map(u => u + dym(u)).join(', ');
15258
15420
  const supported = [...all].join(' ');
15259
- const en = _uiLang(process.cwd()) === 'en';
15421
+ const en = _errorUiLang(process.cwd()) === 'en';
15260
15422
  failJson(has('--json'), 'unknown_flag', en
15261
15423
  ? `Unsupported option for this command: ${detail} (supported: ${supported}${usageHint ? ' · ' + usageHint : ''}) — rejected instead of silently discarding its value`
15262
15424
  : `이 명령에서 지원하지 않는 옵션: ${detail} (지원: ${supported}${usageHint ? ' · ' + usageHint : ''}) — 값이 조용히 버려지는 것을 방지하기 위해 거부`);
@@ -15270,10 +15432,13 @@ function _argvLongFlagNames() {
15270
15432
  }
15271
15433
 
15272
15434
  // T-0144: 모든 명령에서 이름 자체가 없는 플래그는 migration/usage/write 이전에 fail-closed.
15273
- function _rejectUnregisteredFlags() {
15274
- const unknown = _argvLongFlagNames().filter(name => !_VALUE_FLAGS.has(name) && !_BOOL_FLAGS.has(name));
15435
+ function _rejectUnregisteredFlags(cmd) {
15436
+ const seen = _argvLongFlagNames();
15437
+ const unknown = seen.filter(name => !_VALUE_FLAGS.has(name) && !_BOOL_FLAGS.has(name));
15438
+ const leaseOnlyForeign = cmd === 'lease' ? [] : seen.filter(name => name === '--session' || name === '--ttl');
15439
+ for (const name of leaseOnlyForeign) if (!unknown.includes(name)) unknown.push(name);
15275
15440
  if (!unknown.length) return true;
15276
- const en = _uiLang(process.cwd()) === 'en';
15441
+ const en = _errorUiLang(process.cwd()) === 'en';
15277
15442
  const human = en
15278
15443
  ? `Unknown flag: ${unknown.join(' ')} — no such flag in leerness. Its value could otherwise be absorbed into text or a path. Check: leerness --help`
15279
15444
  : `알 수 없는 플래그: ${unknown.join(' ')} — leerness 에 없는 이름입니다. 값이 본문이나 경로 인자에 섞이지 않도록 거부했습니다. 확인: leerness --help`;
@@ -15317,16 +15482,27 @@ function _rejectMalformedFlagForms(cmd, args) {
15317
15482
  if (cmd === 'gate' && name === '--require-referee') continue;
15318
15483
  errors.push({ kind: 'missing_value', name, token });
15319
15484
  }
15320
- else if (valueChoices[name]) {
15485
+ else {
15321
15486
  const value = eqAt >= 0 ? token.slice(eqAt + 1) : String(raw[i + 1]);
15322
- if (!valueChoices[name].has(value.toLowerCase())) errors.push({ kind: 'invalid_value', name, token, value, choices: [...valueChoices[name]] });
15487
+ if (valueChoices[name] && !valueChoices[name].has(value.toLowerCase())) {
15488
+ errors.push({ kind: 'invalid_value', name, token, value, choices: [...valueChoices[name]] });
15489
+ }
15490
+ else if (cmd === 'lease' && name === '--ttl') {
15491
+ const ttl = Number(value);
15492
+ if (!Number.isInteger(ttl) || ttl < 30 || ttl > 1800) errors.push({ kind: 'invalid_value', name, token, value, choices: ['integer 30..1800'] });
15493
+ }
15494
+ else if (cmd === 'lease' && name === '--session') {
15495
+ let valid = false;
15496
+ try { valid = require('../lib/session-presence').isValidKey(value); } catch { valid = false; }
15497
+ if (!valid) errors.push({ kind: 'invalid_value', name, token, value: _lineSafe(value), choices: ['stable 8..64 character session key'] });
15498
+ }
15323
15499
  }
15324
15500
  }
15325
15501
  }
15326
15502
  if (!errors.length) return true;
15327
15503
  const onlyMissing = errors.every(e => e.kind === 'missing_value');
15328
15504
  const onlyInvalidValue = errors.every(e => e.kind === 'invalid_value');
15329
- const en = _uiLang(process.cwd()) === 'en';
15505
+ const en = _errorUiLang(process.cwd(), cmd) === 'en';
15330
15506
  const detail = errors.map(e => {
15331
15507
  if (e.kind === 'missing_value') {
15332
15508
  if (e.name === '--path') return en
@@ -15344,6 +15520,27 @@ function _rejectMalformedFlagForms(cmd, args) {
15344
15520
  return false;
15345
15521
  }
15346
15522
 
15523
+ function _rejectDuplicateValueFlags(groups) {
15524
+ const raw = process.argv.slice(2);
15525
+ for (const groupValue of groups || []) {
15526
+ const group = Array.isArray(groupValue) ? groupValue : [groupValue];
15527
+ let count = 0;
15528
+ for (let i = 0; i < raw.length; i++) {
15529
+ const token = String(raw[i] == null ? '' : raw[i]);
15530
+ if (token === '--') break;
15531
+ const flag = group.find(name => token === name || token.startsWith(name + '='));
15532
+ if (!flag) continue;
15533
+ count += 1;
15534
+ if (token === flag && i + 1 < raw.length && !String(raw[i + 1]).startsWith('--')) i += 1;
15535
+ }
15536
+ if (count <= 1) continue;
15537
+ const label = group.join('/');
15538
+ failJson(has('--json'), 'duplicate_flag', `${label} may be specified only once`);
15539
+ return false;
15540
+ }
15541
+ return true;
15542
+ }
15543
+
15347
15544
  // 전역 registry는 "값을 갖는가"만 답한다. 아래 audit 완료 route는 현재 명령이 그 플래그를
15348
15545
  // 실제 소비하는지도 검사한다. 확인하지 않은 97개 표면을 추측 allowlist로 막지 않고 검증된 route부터 확장한다.
15349
15546
  // --path 는 기존 공용 호출 래퍼가 모든 명령에 붙이는 호환 플래그다. 명령이 별도 positional 파일을
@@ -15361,11 +15558,104 @@ const _STRICT_COMMAND_FLAGS = {
15361
15558
  'verify-code': { allowed: ['--build', '--bench', '--strict'], globals: [..._COMMON_COMMAND_FLAGS], usage: 'verify-code [path] [--build] [--bench] [--strict]' },
15362
15559
  'contract verify': { allowed: ['--allow-empty'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]' },
15363
15560
  'intent expand': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'intent expand "<request>" [--json]' },
15561
+ 'lease acquire': { allowed: ['--session', '--ttl', '--note'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lease acquire <file> --session <key> [--ttl 30..1800] [--note <text>] [--json]' },
15562
+ 'lease release': { allowed: ['--session'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lease release <lease-id> --session <key> [--json]' },
15563
+ 'lease list': { allowed: ['--session', '--all'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lease list [--session <key>] [--all] [--json]' },
15564
+ 'lease check': { allowed: ['--session'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lease check <file> [--session <key>] [--json]' },
15565
+ 'roles validate': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles validate [--path .] [--json]' },
15566
+ 'roles set': { allowed: ['--provider', '--to', '--model', '--model-family', '--policy', '--fallback-policy', '--candidate', '--candidate-family', '--remove-candidate', '--clear-candidates', '--persona', '--force'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles set <role> --provider <id> [--model <id>] [--policy strict|balanced|continuity]' },
15567
+ 'roles unset': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles unset <role> [--path .] [--json]' },
15568
+ 'roles suggest': { allowed: ['--apply'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles suggest [--apply] [--path .] [--json]' },
15569
+ 'roles list': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles list [--path .] [--json]' },
15570
+ 'roles catalog': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles catalog [--json]' },
15571
+ 'roles verify': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles verify [--path .] [--json]' },
15364
15572
  };
15573
+ function _validateLeaseCommandShape(cmd, args) {
15574
+ if (cmd !== 'lease') return true;
15575
+ const rawAction = args[1];
15576
+ const action = rawAction == null ? 'list' : rawAction;
15577
+ const choices = new Set(['acquire', 'release', 'list', 'check']);
15578
+ const en = _errorUiLang(process.cwd(), cmd) === 'en';
15579
+ if (!choices.has(action)) {
15580
+ const safe = _lineSafe(String(action == null ? '' : action)).slice(0, 120);
15581
+ failJson(has('--json'), 'unknown_subcommand', en
15582
+ ? `Unknown lease subcommand: ${safe} (choices: acquire, release, list, check)`
15583
+ : `알 수 없는 lease 하위명령: ${safe} (가능: acquire, release, list, check)`);
15584
+ return false;
15585
+ }
15586
+ const expected = action === 'list' ? (rawAction == null ? 1 : 2) : 3;
15587
+ if (args.length < expected || (action !== 'list' && (args[2] == null || args[2] === ''))) {
15588
+ const release = action === 'release';
15589
+ failJson(has('--json'), release ? 'missing_lease_id' : 'missing_file', en
15590
+ ? `lease ${action} requires ${release ? 'a lease ID' : 'a file path'}.`
15591
+ : `lease ${action}에는 ${release ? 'lease ID' : '파일 경로'}가 필요합니다.`);
15592
+ return false;
15593
+ }
15594
+ if (args.length > expected) {
15595
+ const stray = _lineSafe(String(args[expected])).slice(0, 120);
15596
+ failJson(has('--json'), 'too_many_arguments', en
15597
+ ? `Unexpected lease argument: ${stray}`
15598
+ : `불필요한 lease 인자: ${stray}`);
15599
+ return false;
15600
+ }
15601
+ try {
15602
+ const L = require('../lib/file-leases');
15603
+ if (action === 'acquire' || action === 'check') {
15604
+ L.resolveTarget(absRoot(arg('--path', process.cwd())), args[2]);
15605
+ }
15606
+ if (action === 'release' && !/^lease-[0-9a-f]{16}$/.test(String(args[2] || ''))) {
15607
+ throw new L.LeaseError('invalid_lease_id', 'A canonical lease ID is required.');
15608
+ }
15609
+ if (action === 'acquire' || action === 'release') {
15610
+ const SP = require('../lib/session-presence');
15611
+ const explicit = arg('--session', null);
15612
+ if (explicit != null) L.normalizeSessionKey(explicit);
15613
+ else {
15614
+ if (typeof SP.invalidExplicitKey === 'function' && SP.invalidExplicitKey(process.env) != null) {
15615
+ throw new L.LeaseError('invalid_session_key', 'LEERNESS_SESSION_ID is explicitly set but invalid; refusing ambient fallback.');
15616
+ }
15617
+ L.normalizeSessionKey(SP.deriveSessionKey(process.env));
15618
+ }
15619
+ }
15620
+ } catch (error) {
15621
+ const code = error && error.code ? String(error.code) : 'invalid_lease_input';
15622
+ const message = error && error.message ? String(error.message) : 'Invalid lease input.';
15623
+ failJson(has('--json'), code, _lineSafe(message).slice(0, 300));
15624
+ return false;
15625
+ }
15626
+ return true;
15627
+ }
15628
+
15629
+ function _validateRolesCommandShape(cmd, args) {
15630
+ if (cmd !== 'roles' && cmd !== 'role') return true;
15631
+ const sub = String(args[1] || 'list').trim().toLowerCase();
15632
+ const choices = new Set(['list', 'set', 'unset', 'remove', 'rm', 'catalog', 'suggest', 'verify', 'validate']);
15633
+ if (!choices.has(sub)) {
15634
+ failJson(has('--json'), 'unknown_subcommand', `Unknown roles subcommand: ${_lineSafe(sub).slice(0, 80)}`);
15635
+ return false;
15636
+ }
15637
+ const roleOperand = new Set(['set', 'unset', 'remove', 'rm']);
15638
+ const maximum = roleOperand.has(sub) ? 3 : (args[1] == null ? 1 : 2);
15639
+ if (args.length > maximum) {
15640
+ failJson(has('--json'), 'too_many_arguments', `Unexpected roles argument: ${_lineSafe(String(args[maximum])).slice(0, 120)}`);
15641
+ return false;
15642
+ }
15643
+ return true;
15644
+ }
15645
+
15365
15646
  function _validateCommandFlags(cmd, args) {
15647
+ if (cmd === 'lease' && !_rejectDuplicateValueFlags(['--session', '--ttl', '--note', '--path'])) return false;
15648
+ if (cmd === 'agents' && args[1] === 'route'
15649
+ && !_rejectDuplicateValueFlags(['--tier', '--approved-by', '--reason', '--path'])) return false;
15650
+ const roleSub = (cmd === 'roles' || cmd === 'role') ? String(args[1] || 'list').toLowerCase() : null;
15651
+ if (roleSub === 'set' && !_rejectDuplicateValueFlags([['--provider', '--to'], '--model', '--model-family', ['--policy', '--fallback-policy'], '--persona', '--path'])) return false;
15652
+ if (!_validateLeaseCommandShape(cmd, args)) return false;
15653
+ if (!_validateRolesCommandShape(cmd, args)) return false;
15366
15654
  let route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
15367
15655
  if (cmd === 'verify-claim' && args[1] === 'baseline') route = `verify-claim baseline ${args[2] || ''}`.trim();
15368
15656
  if (cmd === 'intent' && args[1] === 'expand') route = 'intent expand';
15657
+ if (cmd === 'lease') route = `lease ${args[1] == null ? 'list' : args[1]}`;
15658
+ if (roleSub) route = `roles ${roleSub === 'remove' || roleSub === 'rm' ? 'unset' : roleSub}`;
15369
15659
  const cfg = _STRICT_COMMAND_FLAGS[route];
15370
15660
  if (!cfg) return true;
15371
15661
  return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
@@ -20697,9 +20987,11 @@ function _allProviders(root) {
20697
20987
  const userOverrides = new Map();
20698
20988
  for (const u of userList) {
20699
20989
  if (!u || !u.id) continue;
20990
+ const providerId = canonicalLegacyProviderIdentity(u.id);
20991
+ if (!providerId) continue;
20700
20992
  // 정상화 — 누락 필드는 빌트인에서 fallback
20701
- const _bi = EXTERNAL_AGENTS.find(a => a.id === u.id);
20702
- const _bin = u.bin || u.id;
20993
+ const _bi = EXTERNAL_AGENTS.find(a => a.id === providerId);
20994
+ const _bin = u.bin || providerId;
20703
20995
  // 1.36.94 (헌트 #16/#21): 정규화가 6개 필드만 복사해 빌트인 `authCheck` 를 **조용히 버렸다** —
20704
20996
  // `provider add codex --bin codex`(사실상 무변경 등록) 한 번으로 인증 축이 영원히 unknown 이 되고,
20705
20997
  // 고위험 fail-closed(provider_not_authenticated)가 경고 없이 꺼졌다(실측: 로그아웃 codex 로 exit 0 확정).
@@ -20707,7 +20999,8 @@ function _allProviders(root) {
20707
20999
  // **같은 bin 일 때만** 빌트인 확인 명령을 물려받는다(다른 실행파일에 남의 하위명령을 쏘면 안 된다).
20708
21000
  // 사용자 JSON 의 authCheck 는 절대 신뢰하지 않는다(임의 명령 주입 경로) — 빌트인에서만 온다.
20709
21001
  // 같은 실행파일이면 물려받는다(별칭 정규화) — 다른 실행파일에 남의 하위명령을 쏘지는 않는다.
20710
- const _inherited = (_bi && _bi.authCheck && _binKey(_bin) === _binKey(_bi.bin)) ? _bi.authCheck : undefined;
21002
+ const _trustedBin = !!(_bi && _trustedBuiltinBinAlias(_bin, _bi.bin));
21003
+ const _inherited = (_bi && _bi.authCheck && _trustedBin) ? _bi.authCheck : undefined;
20711
21004
  // versionArgs 도 **같은 규율**로 다룬다. 종전엔 사용자 값을 그대로 실어 `shell:true` 로 돌렸는데,
20712
21005
  // 메타문자가 있으면 두 번째 명령이 됐다(실측: `agents list` 한 번으로 마커 파일 생성).
20713
21006
  // 한때 "플래그만 허용"으로 좁혔다가 되돌렸다 — `kubectl version --client` · `gh copilot --version` ·
@@ -20717,18 +21010,18 @@ function _allProviders(root) {
20717
21010
  const _uvaOk = !!_uva && _argsSafe(_uva);
20718
21011
  // 폴백은 **같은 실행파일일 때만** 빌트인 값을 쓴다 — 아니면 남의 하위명령(`gh copilot --version`)을
20719
21012
  // 엉뚱한 bin 에 쏜다(검수 실측: `kubectl copilot --version`).
20720
- const _fallbackVa = (_bi && _binKey(_bin) === _binKey(_bi.bin) && _bi.versionArgs) || ['--version'];
21013
+ const _fallbackVa = (_bi && _trustedBin && _bi.versionArgs) || ['--version'];
20721
21014
  // bin 이 명령줄이면 실행 자체를 하지 않는다 — 이 provider 는 '미설치'로 보이고 사유를 말한다.
20722
21015
  const _binOk = _binSafe(_bin);
20723
- userOverrides.set(u.id, {
20724
- id: u.id,
21016
+ userOverrides.set(providerId, {
21017
+ id: providerId,
20725
21018
  bin: _bin,
20726
21019
  ...(_binOk ? {} : { binRejectedReason: `providers.json 의 bin 이 실행파일 이름/경로가 아니어서 실행하지 않았습니다(선행 -·공백·셸 메타문자 불가): ${String(_bin).slice(0, 80)}` }),
20727
- envFlag: u.envFlag || (_bi && _bi.envFlag) || `LEERNESS_ENABLE_${String(u.id).toUpperCase()}`,
21020
+ envFlag: u.envFlag || (_bi && _bi.envFlag) || `LEERNESS_ENABLE_${providerId.toUpperCase()}`,
20728
21021
  versionArgs: _uvaOk ? _uva : _fallbackVa,
20729
21022
  ...((_uva && !_uvaOk) ? { versionArgsRejectedReason: `providers.json 의 versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다: ${JSON.stringify(_uva).slice(0, 120)}` } : {}),
20730
- desc: u.desc || (_bi && _bi.desc) || `(user) ${u.id}`,
20731
- descEn: u.desc || (_bi && _bi.descEn) || (_bi && _bi.desc) || `(user) ${u.id}`,
21023
+ desc: u.desc || (_bi && _bi.desc) || `(user) ${providerId}`,
21024
+ descEn: u.desc || (_bi && _bi.descEn) || (_bi && _bi.desc) || `(user) ${providerId}`,
20732
21025
  installHint: u.installHint || (_bi && _bi.installHint) || '',
20733
21026
  installCmd: u.installCmd || (_bi && _bi.installCmd) || '',
20734
21027
  ...(_inherited ? { authCheck: _inherited } : {}),
@@ -20759,7 +21052,7 @@ function providerCmd(root, sub, ...args) {
20759
21052
  if (!sub || sub === 'list') {
20760
21053
  const all = _allProviders(root);
20761
21054
  const userList = _readUserProviders(root);
20762
- const userIds = new Set(userList.map(u => u.id));
21055
+ const userIds = new Set(userList.map(u => canonicalLegacyProviderIdentity(u && u.id)).filter(Boolean));
20763
21056
  if (has('--json')) {
20764
21057
  log(JSON.stringify({
20765
21058
  total: all.length,
@@ -20799,9 +21092,10 @@ function providerCmd(root, sub, ...args) {
20799
21092
  return;
20800
21093
  }
20801
21094
  if (sub === 'add') {
20802
- const id = (args[0] || arg('--id', '')).trim();
20803
- if (!id) return fail('provider add <id> 필요 (예: openrouter)');
20804
- if (!/^[a-z][a-z0-9_-]*$/i.test(id)) return fail(`잘못된 id: ${id} (영문자/숫자/_- 만 허용)`);
21095
+ const rawId = String(args[0] || arg('--id', '')).trim();
21096
+ const id = canonicalProviderIdentity(rawId);
21097
+ if (!rawId) return fail('provider add <id> 필요 (예: openrouter)');
21098
+ if (!id || !/^[a-z][a-z0-9_-]*$/.test(id)) return fail(`잘못된 id: ${rawId} (정규 ASCII 영문자/숫자/_- 만 허용)`);
20805
21099
  const bin = arg('--bin', id);
20806
21100
  const envFlag = arg('--env-flag', `LEERNESS_ENABLE_${id.toUpperCase()}`);
20807
21101
  const versionArgs = (arg('--version-args', '--version') || '--version').split(/\s+/).filter(Boolean);
@@ -20809,7 +21103,7 @@ function providerCmd(root, sub, ...args) {
20809
21103
  const installHint = arg('--install-hint', '');
20810
21104
  const userList = _readUserProviders(root);
20811
21105
  // 중복 id 처리: 빌트인 override 또는 user 갱신
20812
- const existingIdx = userList.findIndex(u => u.id === id);
21106
+ const existingIdx = userList.findIndex(u => canonicalLegacyProviderIdentity(u && u.id) === id);
20813
21107
  const entry = { id, bin, envFlag, versionArgs, desc, installHint };
20814
21108
  if (existingIdx >= 0) userList[existingIdx] = entry;
20815
21109
  else userList.push(entry);
@@ -20819,14 +21113,15 @@ function providerCmd(root, sub, ...args) {
20819
21113
  return;
20820
21114
  }
20821
21115
  if (sub === 'remove') {
20822
- const id = (args[0] || arg('--id', '')).trim();
20823
- if (!id) return fail('provider remove <id> 필요');
20824
- if (EXTERNAL_AGENTS.some(b => b.id === id) && !_readUserProviders(root).some(u => u.id === id)) {
21116
+ const rawId = String(args[0] || arg('--id', '')).trim();
21117
+ const id = canonicalLegacyProviderIdentity(rawId);
21118
+ if (!rawId || !id) return fail('provider remove <id> 필요');
21119
+ if (EXTERNAL_AGENTS.some(b => b.id === id) && !_readUserProviders(root).some(u => canonicalLegacyProviderIdentity(u && u.id) === id)) {
20825
21120
  return fail(`${id} 는 빌트인 — 제거 불가 (override 만 제거 가능)`);
20826
21121
  }
20827
21122
  const userList = _readUserProviders(root);
20828
21123
  const before = userList.length;
20829
- const filtered = userList.filter(u => u.id !== id);
21124
+ const filtered = userList.filter(u => canonicalLegacyProviderIdentity(u && u.id) !== id);
20830
21125
  if (filtered.length === before) return fail(`사용자 정의 provider ${id} 없음`);
20831
21126
  _writeUserProviders(root, filtered);
20832
21127
  ok(`provider 제거: ${id}`);
@@ -20887,11 +21182,12 @@ function providerCmd(root, sub, ...args) {
20887
21182
  log(`발견 ${entries.length}개 후보`);
20888
21183
  log('');
20889
21184
  const userList = _readUserProviders(root);
20890
- const existingIds = new Set(userList.map(u => u.id));
20891
- const validIdRegex = /^[a-z][a-z0-9_-]*$/i;
21185
+ const existingIds = new Set(userList.map(u => canonicalLegacyProviderIdentity(u && u.id)).filter(Boolean));
21186
+ const validIdRegex = /^[a-z][a-z0-9_-]*$/;
20892
21187
  let added = 0, updated = 0, skipped = 0;
20893
21188
  for (const e of entries) {
20894
- if (!e.id || !e.bin || !validIdRegex.test(e.id)) { skipped++; continue; }
21189
+ const providerId = e && canonicalProviderIdentity(e.id);
21190
+ if (!providerId || !e.bin || !validIdRegex.test(providerId)) { skipped++; continue; }
20895
21191
  // 1.36.94: **원격 catalog 는 실행 인자를 정하지 못한다.** 여기 저장된 값은 나중에 `agents list/check`
20896
21192
  // 같은 읽기성 명령에서 `shell:true` 로 실행된다 — 즉 원격 문자열이 곧 로컬 실행이다.
20897
21193
  // 메타문자만 막는 것으로는 부족하다: `npm exec --yes <패키지>` 처럼 **메타문자 없이도** 임의 코드를
@@ -20900,32 +21196,35 @@ function providerCmd(root, sub, ...args) {
20900
21196
  // bin 은 형식 검증을 통과한 것만 받는다 — 실행파일 이름/경로가 아니면 그 자체가 명령줄이 된다.
20901
21197
  if (!_binSafe(e.bin)) { skipped++; log(` ⚠ ${e.id} 무시 — bin 이 실행파일 이름/경로가 아닙니다: ${String(e.bin).slice(0, 60)}`); continue; }
20902
21198
  const entry = {
20903
- id: e.id,
21199
+ id: providerId,
20904
21200
  bin: e.bin,
20905
- envFlag: e.envFlag || `LEERNESS_ENABLE_${String(e.id).toUpperCase()}`,
21201
+ envFlag: e.envFlag || `LEERNESS_ENABLE_${providerId.toUpperCase()}`,
20906
21202
  versionArgs: ['--version'],
20907
- desc: e.desc || `(synced) ${e.id}`,
21203
+ desc: e.desc || `(synced) ${providerId}`,
20908
21204
  installHint: e.installHint || ''
20909
21205
  };
20910
21206
  if (e.versionArgs) log(` ⓘ ${e.id}: 원격 versionArgs 는 사용하지 않습니다(--version 고정) — 원격 값이 로컬 실행 인자가 되지 않게 합니다`);
20911
- if (existingIds.has(e.id)) {
20912
- const idx = userList.findIndex(u => u.id === e.id);
21207
+ if (existingIds.has(providerId)) {
21208
+ const idx = userList.findIndex(u => canonicalLegacyProviderIdentity(u && u.id) === providerId);
20913
21209
  if (!dryRun) userList[idx] = entry;
20914
21210
  updated++;
20915
- log(` ↺ ${e.id} (갱신)`);
21211
+ log(` ↺ ${providerId} (갱신)`);
20916
21212
  } else {
20917
21213
  if (!dryRun) userList.push(entry);
20918
- existingIds.add(e.id);
21214
+ existingIds.add(providerId);
20919
21215
  added++;
20920
- log(` + ${e.id} (신규)`);
21216
+ log(` + ${providerId} (신규)`);
20921
21217
  }
20922
21218
  }
20923
21219
  // 1.36.108 (T-0097): 네트워크는 락 밖에서 끝났다. 저장만 락 안에서 하되 **다시 읽어** 병합한다 —
20924
21220
  // 위의 userList 는 fetch 전 스냅샷이라, 그 사이 `provider add` 한 항목을 그대로 덮으면 지운다.
20925
21221
  if (!dryRun) _withLock(_providersFile(root), () => {
20926
21222
  const fresh = _readUserProviders(root);
20927
- const byId = new Map(fresh.map(u => [u.id, u]));
20928
- for (const u of userList) byId.set(u.id, u); // 이번 sync 결과가 이긴다(그게 sync 의 의미다)
21223
+ const byId = new Map(fresh.map(u => [canonicalLegacyProviderIdentity(u && u.id), u]).filter(([id]) => id));
21224
+ for (const u of userList) {
21225
+ const persistedId = canonicalLegacyProviderIdentity(u && u.id);
21226
+ if (persistedId) byId.set(persistedId, u); // 이번 sync 결과가 이긴다(그게 sync 의 의미다)
21227
+ }
20929
21228
  _writeUserProviders(root, [...byId.values()]);
20930
21229
  });
20931
21230
  log('');
@@ -21143,10 +21442,93 @@ function _binKey(b) {
21143
21442
  return process.platform === 'win32' ? s.toLowerCase() : s;
21144
21443
  }
21145
21444
 
21445
+ function _trustedBuiltinBinAlias(candidate, builtin) {
21446
+ const raw = String(candidate || '');
21447
+ // A workspace-relative/absolute path is a different executable authority even
21448
+ // when its basename looks like a built-in CLI. Bare Windows shim extensions and
21449
+ // casing remain compatible because PATH would resolve those as the same command.
21450
+ if (!raw || /[\\/]/.test(raw) || /^[A-Za-z]:/.test(raw)) return false;
21451
+ return _binKey(raw) === _binKey(builtin);
21452
+ }
21453
+
21454
+ function _providerOutputSafe(value, max = 80) {
21455
+ // Provider output is untrusted display text. Compatibility-normalize it before
21456
+ // redaction so full-width credential labels cannot evade the ASCII secret
21457
+ // patterns, then remove terminal/default-ignorable formatting.
21458
+ const visible = stripDefaultIgnorables(value, { nfkc: true, visibleIdentity: true });
21459
+ const withoutAccounts = _lineSafe(visible)
21460
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '***')
21461
+ // Provider auth output has no stable delimiter for display names or orgs.
21462
+ // Once an identity label appears, redact the remainder of that flattened
21463
+ // line instead of leaking every token after the first space.
21464
+ .replace(/\b((?:logged|signed)\s+in(?:\s+as)?\s*[:=]?)\s*[^\r\n]*/gi, '$1 ***')
21465
+ .replace(/\b(account|user(?:name)?|profile|tenant|org(?:anization)?)(?:\s+id)?\s*[:=]\s*[^\r\n]*/gi, '$1=***');
21466
+ return redactSecrets(withoutAccounts, max).trim().slice(0, max) || null;
21467
+ }
21468
+
21469
+ // Presence-only provider inspection. Disabled providers must not be executed merely to
21470
+ // populate an observation table. Windows reuses the shell-free portable resolver; POSIX
21471
+ // searches PATH and checks executable permission without spawning the candidate.
21472
+ function _commandPresentWithoutExecution(bin, options = {}) {
21473
+ if (!_binSafe(bin)) return false;
21474
+ const env = options.env || process.env;
21475
+ const cwd = options.cwd || process.cwd();
21476
+ const isFileExecutable = (candidate) => {
21477
+ try {
21478
+ const stat = fs.statSync(candidate);
21479
+ if (!stat.isFile()) return false;
21480
+ if (process.platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
21481
+ return true;
21482
+ } catch { return false; }
21483
+ };
21484
+ if (process.platform === 'win32') {
21485
+ try {
21486
+ const launch = resolvePortableLaunch(bin, { env, cwd, execPath: process.execPath });
21487
+ if (launch.kind === 'unresolved' || launch.kind === 'unsupported-shell-script') return false;
21488
+ return isFileExecutable(launch.source || launch.file);
21489
+ } catch { return false; }
21490
+ }
21491
+ const value = String(bin);
21492
+ if (/[\/]/.test(value)) {
21493
+ return isFileExecutable(path.isAbsolute(value) ? value : path.resolve(cwd, value));
21494
+ }
21495
+ const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path');
21496
+ const pathValue = pathKey ? String(env[pathKey] || '') : '';
21497
+ for (const rawDir of pathValue.split(path.delimiter)) {
21498
+ const dir = rawDir.trim().replace(/^"|"$/g, '');
21499
+ if (!dir) continue;
21500
+ const absoluteDir = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
21501
+ if (isFileExecutable(path.join(absoluteDir, value))) return true;
21502
+ }
21503
+ return false;
21504
+ }
21505
+
21506
+ function _providerProbeEnv() {
21507
+ const out = _scrubTestEnv();
21508
+ // `_scrubTestEnv` is designed for arbitrary project test runners and therefore keeps
21509
+ // explicitly allowed provider/release environment variables. Provider inspection has a
21510
+ // stricter contract: no project credential/profile/endpoint setting is needed for PATH,
21511
+ // version, or local-auth observation. Remove the entire explicit dotenv surface plus
21512
+ // Leerness/NPM configuration before invoking a third-party provider executable.
21513
+ const explicit = new Set([..._DOTENV_EXPLICIT_KEYS].map(key => key.toUpperCase()));
21514
+ for (const key of Object.keys(out)) {
21515
+ const canonical = key.toUpperCase();
21516
+ if (explicit.has(canonical) || canonical.startsWith('LEERNESS_') || canonical.startsWith('NPM_CONFIG_')) delete out[key];
21517
+ }
21518
+ return out;
21519
+ }
21520
+
21146
21521
  function _checkAgent(agent, opts = {}) {
21147
21522
  const enabled = process.env[agent.envFlag] === '1';
21148
21523
  // PATH 존재 확인 (which / where)
21149
21524
  let installed = false, version = null, error = null;
21525
+ let versionCheckAttempted = false, versionCheckSucceeded = false, presenceCheckOnly = false;
21526
+ // Provider probes inherit only execution essentials. Project API keys, profiles, endpoints,
21527
+ // Leerness flags, and npm configuration are removed before third-party CLI invocation.
21528
+ const probeEnv = _providerProbeEnv();
21529
+ // Disabled providers are presence-only. Merely listing/resolving roles must
21530
+ // never execute a workspace-configured binary whose opt-in flag is unset.
21531
+ const executeVersion = enabled && opts.executeVersion !== false;
21150
21532
  // 안전하지 않은 versionArgs 는 **실행하지 않고** 빌트인 기본값으로 되돌린다(거부보다 낫다 — 설치 확인 자체는 해야 한다).
21151
21533
  const _vaRaw = agent.versionArgs;
21152
21534
  const _vaSafe = _argsSafe(_vaRaw);
@@ -21155,25 +21537,36 @@ function _checkAgent(agent, opts = {}) {
21155
21537
  const _binOk = _binSafe(agent.bin);
21156
21538
  try {
21157
21539
  if (!_binOk) throw new Error('bin 형식 거부');
21158
- const r = _spawnPortableArgvSync(agent.bin, _versionArgs, { encoding: 'utf8', timeout: 5000 });
21159
- if (r.status === 0 || (r.stdout && r.stdout.trim())) {
21160
- installed = true;
21161
- version = (r.stdout || r.stderr || '').trim().split('\n')[0].slice(0, 80);
21162
- } else if (r.error) {
21163
- error = r.error.code || r.error.message;
21540
+ installed = _commandPresentWithoutExecution(agent.bin, { env: probeEnv, cwd: opts.cwd });
21541
+ if (!installed) {
21542
+ error = 'not found on PATH';
21543
+ } else if (!executeVersion) {
21544
+ presenceCheckOnly = true;
21164
21545
  } else {
21165
- error = `exit ${r.status}`;
21546
+ versionCheckAttempted = true;
21547
+ const r = _spawnPortableArgvSync(agent.bin, _versionArgs, {
21548
+ encoding: 'utf8', timeout: 5000, env: probeEnv, cwd: opts.cwd || undefined,
21549
+ });
21550
+ if (r.status === 0) {
21551
+ versionCheckSucceeded = true;
21552
+ version = _providerOutputSafe((r.stdout || r.stderr || '').split('\n')[0], 80);
21553
+ } else if (r.error) {
21554
+ error = r.error.code || r.error.message;
21555
+ } else {
21556
+ error = `exit ${r.status}`;
21557
+ }
21166
21558
  }
21167
21559
  } catch (e) { error = e.message; }
21168
21560
  // 1.36.91: **설치됨 ≠ 사용가능**. 위 확인은 바이너리가 뜨는지만 본다 — 인증·모델 사용권한·쿼터·과금 권한은
21169
21561
  // 아무것도 증명하지 않는다. 무료·비대화형·부작용 없음이 확인된 authCheck 를 가진 provider 만 실제로 판정하고,
21170
21562
  // 나머지는 'unknown' 으로 **남긴다**(모르는 것을 안다고 말하지 않는다). opts.auth 일 때만 실행 — 지연 비용이 있다.
21171
21563
  let auth = 'unknown', authEvidence = null, authSource = null;
21172
- if (installed && opts.auth && agent.authCheck && _argsSafe(agent.authCheck.args)) {
21173
- authSource = `${agent.bin} ${agent.authCheck.args.join(' ')}`;
21564
+ if (enabled && installed && opts.auth && agent.authCheck && _argsSafe(agent.authCheck.args)) {
21565
+ authSource = `${_binKey(agent.bin)} ${agent.authCheck.args.join(' ')}`;
21174
21566
  try {
21175
21567
  const ar = _spawnPortableArgvSync(agent.bin, agent.authCheck.args, {
21176
21568
  encoding: 'utf8', timeout: agent.authCheck.timeoutMs || 8000, stdio: ['ignore', 'pipe', 'pipe'],
21569
+ env: probeEnv, cwd: opts.cwd || undefined,
21177
21570
  });
21178
21571
  const aout = ((ar.stdout || '') + (ar.stderr || '')).trim();
21179
21572
  // **"실행하지 못함"을 "확실히 미인증"으로 둔갑시키지 않는다.** `no` 는 고위험 작업을 차단하므로,
@@ -21184,7 +21577,7 @@ function _checkAgent(agent, opts = {}) {
21184
21577
  || /\n\s+at .+:\d+:\d+/.test(aout)
21185
21578
  || ar.status === 127 || ar.status === 9009;
21186
21579
  // evidence 는 사람이 보는 출력·JSON·CI 로그에 실린다 — 토큰 마스킹 + 제어문자(터미널 이스케이프) 제거.
21187
- const _clean = (s) => require('../lib/pure-utils').redactSecrets(String(s).replace(new RegExp('[\\u0000-\\u001f\\u007f]', 'g'), ' '), 80).trim();
21580
+ const _clean = (s) => _providerOutputSafe(s, 80);
21188
21581
  if (ar.error || ar.status == null) { auth = 'unknown'; authEvidence = `확인 실패(${(ar.error && ar.error.code) || 'timeout'})`; }
21189
21582
  else if (launcherish) { auth = 'unknown'; authEvidence = `확인 명령을 실행하지 못함(exit ${ar.status})`; }
21190
21583
  else if (ar.status === 0) { auth = 'ok'; authEvidence = _clean(aout.split('\n')[0]) || 'exit 0'; }
@@ -21203,6 +21596,7 @@ function _checkAgent(agent, opts = {}) {
21203
21596
  return {
21204
21597
  id: agent.id, bin: agent.bin, desc: agent.desc, envFlag: agent.envFlag,
21205
21598
  enabled, installed, version, error,
21599
+ versionCheckAttempted, versionCheckSucceeded, presenceCheckOnly, probeEnvironmentSanitized: true,
21206
21600
  auth, authEvidence, authSource,
21207
21601
  // 확인 명령이 없는 사유를 구분해 전달한다 — "원래 없음"과 "override 로 물려받지 못함"은 다른 말이다(헌트 #21).
21208
21602
  authCheckDroppedReason: agent.authCheckDroppedReason || null,
@@ -21722,26 +22116,75 @@ function _harnessBrief() {
21722
22116
  return "[leerness 위임 프로토콜] 시작: 'leerness handoff .' 로 컨텍스트/active rules 적재 후 'leerness task add' 로 이 작업 등록. 완료 전: evidence(수정 파일 경로 + 실제 테스트 결과)를 'leerness task update <T-ID> --status done --evidence ...' 로 기록하고 'leerness verify-claim <T-ID> --run-tests' 로 자기검증. 종료: 'leerness session close .' 실행. --- 작업: ";
21723
22117
  }
21724
22118
 
21725
- function _dispatchCommand(agentId, task, writeMode, model) {
22119
+ // Dispatch prepares a copyable shell command, even for providers whose direct
22120
+ // execution path has a structured argv contract. That representation cannot
22121
+ // safely carry every string across cmd, PowerShell, and POSIX shells. Keep it
22122
+ // only for portable plain text; callers with shell syntax must use a structured
22123
+ // execution path instead of receiving an executable-looking unsafe string.
22124
+ function _portableDispatchTaskSafe(value) {
22125
+ const text = String(value == null ? '' : value);
22126
+ if (!text) return false;
22127
+ // PowerShell treats typographic quotes as real delimiters, including U+201C
22128
+ // and U+201D. ASCII apostrophe is the sole quotation mark safe inside the
22129
+ // emitted ASCII double-quoted argument; reject every other Unicode quote.
22130
+ if (/\p{Quotation_Mark}/u.test(text.replace(/'/g, ''))) return false;
22131
+ // NFKC exposes full-width shell metacharacters before applying the same deny
22132
+ // set. Cc covers the C1 range too; Cf/Zl/Zp prevent invisible line/bidi tricks.
22133
+ return [text, text.normalize('NFKC')].every(view =>
22134
+ !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\`$%!^]/u.test(view));
22135
+ }
22136
+
22137
+ function _dispatchCommand(agentId, task, writeMode, model, providerDefinition) {
21726
22138
  const q = String(task || '').replace(/"/g, '\\"');
21727
- const m = model ? String(model).replace(/"/g, '') : '';
21728
- if (agentId === 'claude') return `claude ${writeMode ? '--print --dangerously-skip-permissions' : '--print'}${m ? ` --model ${m}` : ''} "${q}"`;
21729
- if (agentId === 'codex') return `codex ${writeMode ? 'exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox' : 'exec --skip-git-repo-check'}${m ? ` -m ${m}` : ''} "${q}"`;
21730
- if (agentId === 'agy') return `agy ${writeMode ? '-p --yolo' : '-p'}${m ? ` --model ${m}` : ''} "${q}"`;
21731
- if (agentId === 'grok') return `grok${writeMode ? ' --yolo' : ''}${m ? ` --model ${m}` : ''} "${q}"`;
22139
+ const m = model ? String(model).trim() : '';
22140
+ if (m && !isValidModelIdentifier(m)) {
22141
+ const error = new Error('model is not a safe model identifier');
22142
+ error.code = 'invalid_model_identifier';
22143
+ throw error;
22144
+ }
22145
+ const definition = providerDefinition && providerDefinition.id === agentId ? providerDefinition : null;
22146
+ const defaultBin = agentId === 'copilot' ? 'gh' : agentId;
22147
+ const executable = definition && definition.bin ? definition.bin : defaultBin;
22148
+ if (!_binSafe(executable) || (definition && definition.binRejectedReason)) {
22149
+ const error = new Error(`provider ${agentId} has no safe executable definition`);
22150
+ error.code = 'provider_dispatch_definition_invalid';
22151
+ throw error;
22152
+ }
22153
+ if (!_portableDispatchTaskSafe(task)) {
22154
+ const error = new Error(`provider ${agentId} cannot safely represent this task as a portable shell command; use a structured argv execution path`);
22155
+ error.code = 'provider_dispatch_task_unsafe';
22156
+ throw error;
22157
+ }
22158
+ if (agentId === 'claude') return `${executable} ${writeMode ? '--print --dangerously-skip-permissions' : '--print'}${m ? ` --model ${m}` : ''} "${q}"`;
22159
+ if (agentId === 'codex') return `${executable} ${writeMode ? 'exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox' : 'exec --skip-git-repo-check'}${m ? ` -m ${m}` : ''} "${q}"`;
22160
+ if (agentId === 'agy') return `${executable} ${writeMode ? '-p --yolo' : '-p'}${m ? ` --model ${m}` : ''} "${q}"`;
22161
+ if (agentId === 'grok') return `${executable}${writeMode ? ' --yolo' : ''}${m ? ` --model ${m}` : ''} "${q}"`;
21732
22162
  // 1.9.277: 신규 provider 4종 — best-effort 비대화형 호출 (실제 실행은 사용자/메인)
21733
- if (agentId === 'opencode') return `opencode run${m ? ` --model ${m}` : ''} "${q}"`;
21734
- if (agentId === 'qwen') return `qwen -p "${q}"${m ? ` # --model ${m} (env/설정)` : ''}`;
21735
- if (agentId === 'aider') return `aider --message "${q}"${writeMode ? ' --yes' : ' --no-auto-commits'}${m ? ` --model ${m}` : ''}`;
21736
- if (agentId === 'goose') return `goose run -t "${q}"${m && m !== 'default' ? ` # provider/model: goose configure` : ''}`;
21737
- if (agentId === 'copilot') return `gh copilot suggest "${q}"`;
21738
- if (agentId === 'ollama') return `# ollama — leerness agent "${q}" --provider ollama${m ? ` --model ${m}` : ''} 로 직접 호출 (REPL: leerness agent)`;
21739
- return `# ${agentId}: 명령 빌더 미정의`;
22163
+ if (agentId === 'opencode') return `${executable} run${m ? ` --model ${m}` : ''} "${q}"`;
22164
+ if (agentId === 'qwen') return `${executable} -p "${q}"${m ? ` # --model ${m} (env/설정)` : ''}`;
22165
+ if (agentId === 'aider') return `${executable} --message "${q}"${writeMode ? ' --yes' : ' --no-auto-commits'}${m ? ` --model ${m}` : ''}`;
22166
+ if (agentId === 'goose') return `${executable} run -t "${q}"${m && m !== 'default' ? ` # provider/model: goose configure` : ''}`;
22167
+ if (agentId === 'copilot') return `${executable} copilot suggest "${q}"`;
22168
+ if (agentId === 'ollama') return `# ${executable} — leerness agent "${q}" --provider ollama${m ? ` --model ${m}` : ''} 로 직접 호출 (REPL: leerness agent)`;
22169
+ if (!definition) {
22170
+ const error = new Error(`provider ${agentId} has no registered dispatch definition`);
22171
+ error.code = 'provider_dispatch_definition_missing';
22172
+ throw error;
22173
+ }
22174
+ // Legacy/user registries define an executable and use the registry's historic
22175
+ // generic positional-prompt/--model convention. They do not define a trusted
22176
+ // permission-widening flag, so an explicit write request fails closed.
22177
+ if (writeMode) {
22178
+ const error = new Error(`provider ${agentId} does not define ${writeMode ? 'write-mode' : 'model'} invocation semantics`);
22179
+ error.code = 'provider_dispatch_semantics_undefined';
22180
+ throw error;
22181
+ }
22182
+ return `${executable}${m ? ` --model ${m}` : ''} "${q}"`;
21740
22183
  }
21741
22184
 
21742
22185
  const _agents = require('../lib/agents');
21743
22186
  // 1.9.424 (UR-0025/UR-0125 큰 핸들러 모듈화 9번째): agentsCmd → lib/agents.js (DI 위임, rest→array)
21744
- function agentsCmd(root, sub, ...args) { return _agents.agentsCmd(root, sub, args, { VERSION, has, arg, uiLang: _uiLang(root), _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief: _tgl.toggleOn(root, 'delegation-brief') ? _harnessBrief : undefined, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, lessonsPath, taskLogPath }); } // 1.36.30: delegation-brief 토글 OFF 면 브리프 미접두(=--raw 경로)
22187
+ function agentsCmd(root, sub, ...args) { return _agents.agentsCmd(root, sub, args, { VERSION, has, arg, rawArgv: process.argv.slice(2), uiLang: _uiLang(root), _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief: _tgl.toggleOn(root, 'delegation-brief') ? _harnessBrief : undefined, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, _withLock, lessonsPath, taskLogPath }); } // 1.36.30: delegation-brief 토글 OFF 면 브리프 미접두(=--raw 경로)
21745
22188
 
21746
22189
  function personaCmd(root, sub, idOrName, ...rest) {
21747
22190
  root = absRoot(root || process.cwd());
@@ -26064,6 +26507,33 @@ function skillExportAllCmd(root) {
26064
26507
 
26065
26508
  // 1.9.322 (UR-0044, 설치리뷰): MCP tools/call name->cliArgs 매핑을 단일 함수로 통합 (이전: mcpServeCmd 인라인 83-case switch).
26066
26509
  // 일치성 가드(selftest: 모든 도구 def 가 case 보유) + e2e(대표 도구 dispatch) 가 안전망. 미지 도구는 null 반환.
26510
+ function _fileLeaseMcpParamError(name, args) {
26511
+ if (name !== 'leerness_file_lease_read' && name !== 'leerness_file_lease_write') return null;
26512
+ const own = key => Object.prototype.hasOwnProperty.call(args, key);
26513
+ const action = args.action;
26514
+ const allowedActions = name === 'leerness_file_lease_read' ? ['list', 'check'] : ['acquire', 'release'];
26515
+ if (!allowedActions.includes(action)) return `Invalid params: 'action' must be one of ${allowedActions.join('|')}`;
26516
+ const common = new Set(['action', 'path', 'sessionKey']);
26517
+ if (name === 'leerness_file_lease_read') {
26518
+ common.add('_cursor');
26519
+ common.add('_chunkSize');
26520
+ }
26521
+ const allowed = new Set(common);
26522
+ if (action === 'list') allowed.add('all');
26523
+ if (action === 'check') allowed.add('file');
26524
+ if (action === 'acquire') { allowed.add('file'); allowed.add('ttl'); allowed.add('note'); }
26525
+ if (action === 'release') allowed.add('leaseId');
26526
+ const extra = Object.keys(args).find(key => !allowed.has(key));
26527
+ if (extra) return `Invalid params: '${extra}' is not accepted for lease action '${action}'`;
26528
+ if ((action === 'check' || action === 'acquire') && (!own('file') || typeof args.file !== 'string' || !args.file.trim())) {
26529
+ return `Invalid params: lease action '${action}' requires a non-empty 'file'`;
26530
+ }
26531
+ if (action === 'release' && (!own('leaseId') || typeof args.leaseId !== 'string' || !args.leaseId)) {
26532
+ return "Invalid params: lease action 'release' requires a non-empty 'leaseId'";
26533
+ }
26534
+ return null;
26535
+ }
26536
+
26067
26537
  function _mcpToCliArgs(name, args, targetPath) {
26068
26538
  let cliArgs;
26069
26539
  switch (name) {
@@ -26073,7 +26543,83 @@ function _mcpToCliArgs(name, args, targetPath) {
26073
26543
  case 'leerness_verify_claim': cliArgs = ['verify-claim', args.taskId, '--path', targetPath, ...(args.refereeId ? ['--referee', String(args.refereeId)] : []), ...(args.runTests ? ['--run-tests'] : []), ...(args.strictClaims ? ['--strict-claims'] : []), ...(args.lenient ? ['--lenient'] : [])]; break;
26074
26544
  case 'leerness_verify_claim_all': cliArgs = ['verify-claim', '--all', '--path', targetPath, '--json', ...(args.runTests ? ['--run-tests'] : []), ...(args.strictClaims ? ['--strict-claims'] : []), ...(args.lenient ? ['--lenient'] : []), ...(args.raw ? ['--raw'] : [])]; break; // 1.33.3/1.36.167: 일괄검증 + 원판정 감사
26075
26545
  case 'leerness_contract_verify': cliArgs = ['contract', 'verify', args.spec, args.impl]; break;
26076
- case 'leerness_agents_list': cliArgs = ['agents', 'list', '--json']; break;
26546
+ case 'leerness_agents_list': cliArgs = ['agents', 'list', '--path', targetPath, '--json']; break;
26547
+ case 'leerness_agents_resolve':
26548
+ cliArgs = ['agents', 'resolve', String(args.task || ''), '--role', String(args.role || ''), '--path', targetPath, '--json'];
26549
+ if (args.tier) cliArgs.push('--tier', String(args.tier));
26550
+ if (args.preset) cliArgs.push('--preset', String(args.preset));
26551
+ if (args.approvedBy) cliArgs.push('--approved-by', String(args.approvedBy));
26552
+ if (args.reason) cliArgs.push('--reason', String(args.reason));
26553
+ if (args.sessionProvider) cliArgs.push('--session-provider', String(args.sessionProvider));
26554
+ if (args.sessionModel) cliArgs.push('--session-model', String(args.sessionModel));
26555
+ if (args.sessionModelFamily) cliArgs.push('--session-model-family', String(args.sessionModelFamily));
26556
+ if (args.implementerProvider) cliArgs.push('--agent', String(args.implementerProvider));
26557
+ if (args.implementerModel) cliArgs.push('--agent-model', String(args.implementerModel));
26558
+ if (args.implementerModelFamily) cliArgs.push('--agent-model-family', String(args.implementerModelFamily));
26559
+ if (args.implementerSession) cliArgs.push('--agent-session', String(args.implementerSession));
26560
+ break;
26561
+ case 'leerness_agents_fallback':
26562
+ cliArgs = ['agents', 'fallback', String(args.action || ''), String(args.task || ''), '--role', String(args.role || ''), '--path', targetPath, '--json'];
26563
+ if (args.taskId) cliArgs.push('--id', String(args.taskId));
26564
+ if (args.provider) cliArgs.push('--provider', String(args.provider));
26565
+ if (args.model) cliArgs.push('--model', String(args.model));
26566
+ if (args.approvedBy) cliArgs.push('--approved-by', String(args.approvedBy));
26567
+ if (args.reason) cliArgs.push('--reason', String(args.reason));
26568
+ if (args.tier) cliArgs.push('--tier', String(args.tier));
26569
+ if (args.preset) cliArgs.push('--preset', String(args.preset));
26570
+ if (args.write === true) cliArgs.push('--write');
26571
+ if (args.sessionProvider) cliArgs.push('--session-provider', String(args.sessionProvider));
26572
+ if (args.sessionModel) cliArgs.push('--session-model', String(args.sessionModel));
26573
+ if (args.sessionModelFamily) cliArgs.push('--session-model-family', String(args.sessionModelFamily));
26574
+ if (args.implementerProvider) cliArgs.push('--agent', String(args.implementerProvider));
26575
+ if (args.implementerModel) cliArgs.push('--agent-model', String(args.implementerModel));
26576
+ if (args.implementerModelFamily) cliArgs.push('--agent-model-family', String(args.implementerModelFamily));
26577
+ if (args.implementerSession) cliArgs.push('--agent-session', String(args.implementerSession));
26578
+ break;
26579
+ case 'leerness_agents_availability': {
26580
+ const action = String(args.action || 'list');
26581
+ cliArgs = ['agents', 'availability', action];
26582
+ if ((action === 'mark' || action === 'clear') && args.provider) cliArgs.push(String(args.provider));
26583
+ cliArgs.push('--path', targetPath, '--json');
26584
+ if (args.model) cliArgs.push('--model', String(args.model));
26585
+ if (args.modelFamily) cliArgs.push('--model-family', String(args.modelFamily));
26586
+ if (args.reason) cliArgs.push('--reason', String(args.reason));
26587
+ if (args.authenticated) cliArgs.push('--auth-state', String(args.authenticated === 'yes' ? 'ok' : args.authenticated));
26588
+ if (args.entitlement) cliArgs.push('--entitlement', String(args.entitlement));
26589
+ if (args.quota) cliArgs.push('--quota-state', String(args.quota));
26590
+ if (args.policy) cliArgs.push('--policy-state', String(args.policy));
26591
+ if (args.reachability) cliArgs.push('--reachability', String(args.reachability));
26592
+ if (args.rateLimited === true) cliArgs.push('--rate-limited');
26593
+ if (args.rateLimited === false) cliArgs.push('--not-rate-limited');
26594
+ if (args.retryAfter) cliArgs.push('--retry-after', String(args.retryAfter));
26595
+ if (args.expiresAt) cliArgs.push('--expires-at', String(args.expiresAt));
26596
+ if (typeof args.ttlMin === 'number') cliArgs.push('--ttl-min', String(args.ttlMin));
26597
+ if (typeof args.limit === 'number') cliArgs.push('--limit', String(args.limit));
26598
+ break;
26599
+ }
26600
+ case 'leerness_agents_record':
26601
+ cliArgs = ['agents', 'record', String(args.event || ''), String(args.summary || ''), '--path', targetPath, '--json'];
26602
+ if (args.taskId) cliArgs.push('--task', String(args.taskId));
26603
+ if (args.attemptId) cliArgs.push('--id', String(args.attemptId));
26604
+ if (args.targetAttemptId) cliArgs.push('--target', String(args.targetAttemptId));
26605
+ if (args.role) cliArgs.push('--role', String(args.role));
26606
+ if (args.provider) cliArgs.push('--provider', String(args.provider));
26607
+ if (args.model) cliArgs.push('--model', String(args.model));
26608
+ if (args.modelFamily) cliArgs.push('--model-family', String(args.modelFamily));
26609
+ if (args.status) cliArgs.push('--status', String(args.status));
26610
+ if (args.evidence) cliArgs.push('--evidence', String(args.evidence));
26611
+ if (args.implementerProvider) cliArgs.push('--agent', String(args.implementerProvider));
26612
+ if (args.implementerModel) cliArgs.push('--agent-model', String(args.implementerModel));
26613
+ if (args.implementerModelFamily) cliArgs.push('--agent-model-family', String(args.implementerModelFamily));
26614
+ if (args.implementerSession) cliArgs.push('--agent-session', String(args.implementerSession));
26615
+ break;
26616
+ case 'leerness_agents_history':
26617
+ cliArgs = ['agents', 'history', '--path', targetPath, '--json'];
26618
+ if (typeof args.limit === 'number') cliArgs.push('--limit', String(args.limit));
26619
+ break;
26620
+ case 'leerness_agents_quota':
26621
+ cliArgs = ['agents', 'quota', '--path', targetPath, '--json'];
26622
+ break;
26077
26623
  case 'leerness_reuse_map': cliArgs = ['reuse-map', targetPath, ...(args.allApps ? ['--all-apps'] : []), ...(args.strictElements ? ['--strict-elements'] : []), '--json']; break;
26078
26624
  case 'leerness_whats_new': cliArgs = ['whats-new', '--path', targetPath, ...(args.from ? ['--from', args.from] : []), ...(args.to ? ['--to', args.to] : []), ...(args.all ? ['--all'] : []), ...(args.limit ? ['--limit', String(args.limit)] : []), '--json']; break; // 1.36.69 (검수 #1): limit/all 전달 — MCP 소비자도 상한 조절 가능
26079
26625
  case 'leerness_usage_stats': cliArgs = ['usage', 'stats', targetPath, '--json']; break;
@@ -26267,11 +26813,24 @@ function _mcpToCliArgs(name, args, targetPath) {
26267
26813
  if (args.refresh === true) cliArgs.push('--refresh');
26268
26814
  if (args.dryRun === true) cliArgs.push('--dry-run');
26269
26815
  break;
26816
+ case 'leerness_roles_validate':
26817
+ cliArgs = ['roles', 'validate', '--path', targetPath, '--json'];
26818
+ break;
26270
26819
  case 'leerness_roles':
26271
- // 1.9.270 (사용자 명시): 모델별 역할 부여
26820
+ // 역할별 primary/candidate/fallback policy — CLI 단일 구현을 그대로 재사용.
26272
26821
  cliArgs = ['roles', String(args.sub || 'list'), ...(args.role ? [String(args.role)] : []), '--path', targetPath, '--json'];
26273
26822
  if (args.provider) cliArgs.push('--provider', String(args.provider));
26274
26823
  if (args.model) cliArgs.push('--model', String(args.model));
26824
+ if (args.modelFamily) cliArgs.push('--model-family', String(args.modelFamily));
26825
+ const _roleCandidates = Array.isArray(args.candidates) ? args.candidates : (args.candidate ? [args.candidate] : []);
26826
+ const _roleCandidateFamilies = Array.isArray(args.candidateFamilies) ? args.candidateFamilies : (args.candidateFamily ? [args.candidateFamily] : []);
26827
+ const _roleRemoveCandidates = Array.isArray(args.removeCandidates) ? args.removeCandidates : [];
26828
+ for (const candidate of _roleCandidates) cliArgs.push('--candidate', String(candidate));
26829
+ for (const family of _roleCandidateFamilies) cliArgs.push('--candidate-family', String(family));
26830
+ for (const candidate of _roleRemoveCandidates) cliArgs.push('--remove-candidate', String(candidate));
26831
+ if (args.fallbackPolicy) cliArgs.push('--policy', String(args.fallbackPolicy));
26832
+ if (args.persona) cliArgs.push('--persona', String(args.persona));
26833
+ if (args.clearCandidates === true) cliArgs.push('--clear-candidates');
26275
26834
  if (args.apply === true) cliArgs.push('--apply');
26276
26835
  break;
26277
26836
  // 1.9.279 (UR-0031): 상태 substrate MCP 시맨틱 verb
@@ -26296,6 +26855,22 @@ function _mcpToCliArgs(name, args, targetPath) {
26296
26855
  }
26297
26856
  break;
26298
26857
  }
26858
+ case 'leerness_file_lease_read': {
26859
+ const _la = String(args.action || 'list');
26860
+ cliArgs = ['lease', _la, '--path', targetPath, '--json'];
26861
+ if (_la === 'check') cliArgs.splice(2, 0, String(args.file || ''));
26862
+ if (args.sessionKey) cliArgs.push('--session', String(args.sessionKey));
26863
+ if (args.all) cliArgs.push('--all');
26864
+ break;
26865
+ }
26866
+ case 'leerness_file_lease_write': {
26867
+ const _la = String(args.action || 'acquire');
26868
+ cliArgs = ['lease', _la, '--path', targetPath, '--json', '--session', String(args.sessionKey || '')];
26869
+ cliArgs.splice(2, 0, _la === 'release' ? String(args.leaseId || '') : String(args.file || ''));
26870
+ if (_la === 'acquire' && args.ttl !== undefined) cliArgs.push('--ttl', String(args.ttl));
26871
+ if (_la === 'acquire' && args.note) cliArgs.push('--note', String(args.note));
26872
+ break;
26873
+ }
26299
26874
  case 'leerness_preview': {
26300
26875
  const _pa = String(args.action || 'list');
26301
26876
  cliArgs = ['preview', _pa, '--path', targetPath, '--json'];
@@ -26425,6 +27000,9 @@ function mcpServeCmd(root) {
26425
27000
  // (검수) 오타 프로필이 무언 full 로 새지 않게 — 제한 요청이 조용히 무력화되는 fail-open 차단
26426
27001
  if (_profile !== 'core' && _profile !== 'full') { fail(`알 수 없는 MCP 프로필: ${_profile} (가능: core, full)`); return process.exit(1); }
26427
27002
  const TOOLS = _profile === 'core' ? _ALL_TOOLS.filter(t => _ALL_TOOLS.CORE.includes(t.name)) : _ALL_TOOLS;
27003
+ // A stdio MCP process represents one connection. Per-call sessionKey remains
27004
+ // a routing address, but it must not manufacture an independent reviewer.
27005
+ const _mcpConnectionId = `mcp-${require('crypto').randomBytes(16).toString('hex')}`;
26428
27006
 
26429
27007
  function send(obj) {
26430
27008
  process.stdout.write(JSON.stringify(obj) + '\n');
@@ -26442,6 +27020,10 @@ function mcpServeCmd(root) {
26442
27020
  // MCP 서버 프로세스가 바깥 호출의 일회성 presence marker를 물려받았더라도 다음 호출로
26443
27021
  // 재사용하지 않는다. 검증된 direct lifecycle 호출만 아래 extraEnv에서 다시 부여한다.
26444
27022
  _deleteEnvKeyCaseInsensitive(childEnv, 'LEERNESS_MCP_ADDRESS_EXPLICIT');
27023
+ // Connection identity is minted by this stdio server. Never let an inherited
27024
+ // differently-cased Windows environment key compete with the minted value.
27025
+ _deleteEnvKeyCaseInsensitive(childEnv, 'LEERNESS_MCP_CONNECTION_ID');
27026
+ _deleteEnvKeyCaseInsensitive(childEnv, 'LEERNESS_SESSION_IDENTITY_SOURCE');
26445
27027
  // Windows child_process는 env 이름을 대소문자 비구분으로 하나만 전달한다. 호출별 주소를
26446
27028
  // 덮을 때 상속된 다른 casing이 남아 있으면 어느 값이 선택될지 정렬 순서에 의존한다.
26447
27029
  if (extraEnv && Object.prototype.hasOwnProperty.call(extraEnv, 'LEERNESS_SESSION_ID')) {
@@ -26516,18 +27098,31 @@ function mcpServeCmd(root) {
26516
27098
  {
26517
27099
  const _props = _toolDef && _toolDef.inputSchema && _toolDef.inputSchema.properties || null;
26518
27100
  if (_props) {
27101
+ const _invalidParam = message => send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: ${message}` } });
27102
+ if (_toolDef.inputSchema.additionalProperties === false) {
27103
+ const extra = Object.keys(args).find(key => !Object.prototype.hasOwnProperty.call(_props, key));
27104
+ if (extra) return _invalidParam(`unknown property '${extra}'`);
27105
+ }
26519
27106
  for (const [k, spec] of Object.entries(_props)) {
26520
27107
  const v = args[k];
26521
27108
  if (v === undefined || v === null) continue;
26522
- if (spec.type === 'string' && typeof v !== 'string') return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: '${k}' must be a string` } });
26523
- if (spec.type === 'number' && typeof v !== 'number') return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: '${k}' must be a number` } });
26524
- if (spec.type === 'boolean' && typeof v !== 'boolean') return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: '${k}' must be a boolean` } });
27109
+ if (spec.type === 'string' && typeof v !== 'string') return _invalidParam(`'${k}' must be a string`);
27110
+ if (spec.type === 'number' && typeof v !== 'number') return _invalidParam(`'${k}' must be a number`);
27111
+ if (spec.type === 'boolean' && typeof v !== 'boolean') return _invalidParam(`'${k}' must be a boolean`);
27112
+ if (spec.type === 'array') {
27113
+ if (!Array.isArray(v)) return _invalidParam(`'${k}' must be an array`);
27114
+ if (Number.isInteger(spec.maxItems) && v.length > spec.maxItems) return _invalidParam(`'${k}' must contain at most ${spec.maxItems} item(s)`);
27115
+ if (spec.items && spec.items.type === 'string' && v.some(item => typeof item !== 'string')) return _invalidParam(`'${k}' items must be strings`);
27116
+ }
27117
+ if (Array.isArray(spec.enum) && !spec.enum.includes(v)) return _invalidParam(`'${k}' must be one of ${spec.enum.join('|')}`);
26525
27118
  }
26526
27119
  for (const rk of (_toolDef.inputSchema.required || [])) {
26527
- if (args[rk] === undefined || args[rk] === null) return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: missing required '${rk}'` } });
27120
+ if (args[rk] === undefined || args[rk] === null) return _invalidParam(`missing required '${rk}'`);
26528
27121
  }
26529
27122
  }
26530
27123
  }
27124
+ const _leaseParamError = _fileLeaseMcpParamError(name, args);
27125
+ if (_leaseParamError) return send({ jsonrpc: '2.0', id, error: { code: -32602, message: _leaseParamError } });
26531
27126
  // 1.36.171 (외부 Codex P1/P2): sessionKey는 광고한 도구에서만 받으며, 명시된 값은
26532
27127
  // MCP 경계에서 완전히 검증한다. 잘못된 값을 CLI가 무시해 서버의 CODEX_THREAD_ID나 전역
26533
27128
  // currentRunId로 떨어뜨리면 서로 다른 에이전트의 증거가 다시 섞인다.
@@ -26578,8 +27173,13 @@ function mcpServeCmd(root) {
26578
27173
  // 실제로 받은 **직접 handoff/close**에만 붙인다. state/agent-mode/일반 내부 스폰은
26579
27174
  // presence 억제 예외가 아니며, sessionKey 자체는 상태 귀속용 env로만 전달한다.
26580
27175
  const _lifecycle = name === 'leerness_handoff' || name === 'leerness_session_close';
26581
- const _mcpEnv = {};
26582
- if (_sk) _mcpEnv.LEERNESS_SESSION_ID = _sk;
27176
+ const _mcpEnv = {
27177
+ LEERNESS_MCP_CONNECTION_ID: _mcpConnectionId,
27178
+ LEERNESS_SESSION_IDENTITY_SOURCE: 'mcp-connection',
27179
+ };
27180
+ if (_sk) {
27181
+ _mcpEnv.LEERNESS_SESSION_ID = _sk;
27182
+ }
26583
27183
  if (_sk && _lifecycle) _mcpEnv.LEERNESS_MCP_ADDRESS_EXPLICIT = '1';
26584
27184
  if (_mcpReadOnly) _mcpEnv.LEERNESS_NO_AUTO_WORKSPACE_MIGRATION = '1';
26585
27185
  const r = callLeerness(cliArgs, _mcpEnv);
@@ -26591,9 +27191,16 @@ function mcpServeCmd(root) {
26591
27191
  const cursor = (args._cursor && /^\d+$/.test(String(args._cursor))) ? parseInt(args._cursor, 10) : 0;
26592
27192
  const chunk = fullText.slice(cursor, cursor + CHUNK_SIZE);
26593
27193
  const nextCursor = (cursor + CHUNK_SIZE) < fullText.length ? String(cursor + CHUNK_SIZE) : null;
27194
+ let successfulDomainResponse = false;
27195
+ if (name === 'leerness_file_lease_read' && args.action === 'check') {
27196
+ try { successfulDomainResponse = JSON.parse(fullText).ok === true; } catch {}
27197
+ }
26594
27198
  const result = {
26595
27199
  content: [{ type: 'text', text: chunk }],
26596
- isError: !r.ok
27200
+ // A peer-owned lease is a successful check result with
27201
+ // available=false. Keep CLI exit status 1 for shell gating without
27202
+ // mislabeling that normal domain result as an MCP transport/tool error.
27203
+ isError: !r.ok && !successfulDomainResponse
26597
27204
  };
26598
27205
  if (nextCursor) {
26599
27206
  result.nextCursor = nextCursor;
@@ -27097,10 +27704,10 @@ async function _ollamaListModels() {
27097
27704
  async function _cliChat(root, provider, prompt, opts) {
27098
27705
  opts = opts || {};
27099
27706
  const agent = EXTERNAL_AGENTS.find(a => a.id === provider);
27100
- if (!agent) return { ok: false, error: `unknown provider: ${provider}`, provider };
27707
+ if (!agent) return { ok: false, error: `unknown provider: ${provider}`, provider, executed: false };
27101
27708
  const status = _checkAgent(agent);
27102
27709
  if (status.status !== 'ready') {
27103
- return { ok: false, error: `${provider} 비활성 (${status.status}) — .env 에서 ${agent.envFlag}=1 + CLI 설치 필요`, provider };
27710
+ return { ok: false, error: `${provider} 비활성 (${status.status}) — .env 에서 ${agent.envFlag}=1 + CLI 설치 필요`, provider, executed: false };
27104
27711
  }
27105
27712
  // CLI 별 비-인터랙티브 호출 인자 매핑 (read-only 모드 — REPL 안에서 파일 수정 X)
27106
27713
  // 1.9.188 (사용자 명시 fix): 한글/특수문자 prompt 시 shell escape 실패 회피 → stdin 으로 전달.
@@ -27109,7 +27716,7 @@ async function _cliChat(root, provider, prompt, opts) {
27109
27716
  else if (provider === 'codex') { cmd = 'codex'; args = ['exec', '--skip-git-repo-check', '-']; stdinInput = prompt; }
27110
27717
  else if (provider === 'agy') { cmd = 'agy'; args = ['-p', prompt]; } // agy (Antigravity) 는 인자 only
27111
27718
  else if (provider === 'copilot') { cmd = 'gh'; args = ['copilot', 'suggest', prompt]; }
27112
- else return { ok: false, error: `provider ${provider} 미지원`, provider };
27719
+ else return { ok: false, error: `provider ${provider} 미지원`, provider, executed: false };
27113
27720
  // runCommandSafe — env scrub + observability 자동
27114
27721
  const r = runCommandSafe(cmd, args, {
27115
27722
  cwd: process.cwd(), root,
@@ -27119,12 +27726,13 @@ async function _cliChat(root, provider, prompt, opts) {
27119
27726
  input: stdinInput // 1.9.188: claude/codex 는 stdin 전달
27120
27727
  });
27121
27728
  if (r.status === 0) {
27122
- return { ok: true, response: (r.stdout || '').trim(), provider, model: provider };
27729
+ return { ok: true, response: (r.stdout || '').trim(), provider, model: provider, executed: true };
27123
27730
  }
27124
27731
  return {
27125
27732
  ok: false,
27126
27733
  error: `exit=${r.status} ${(r.stderr || r.stdout || '').slice(0, 200)}`,
27127
- provider
27734
+ provider,
27735
+ executed: true,
27128
27736
  };
27129
27737
  }
27130
27738
 
@@ -27564,65 +28172,189 @@ function ciInitCmd(root, opts = {}) {
27564
28172
  log(' ⮕ 다음 단계(가드레일 완성): GitHub branch protection 에서 leerness-gate 를 "required" 체크로 지정 — 그래야 우회 불가.');
27565
28173
  }
27566
28174
  // 1.9.294 (UR-0025 3단계): 역할/모델 카탈로그(_PROVIDER_MODEL_CATALOG + _AGENT_ROLE_PROMPTS + ROLE_CATALOG + _ROLE_ALIASES) 데이터 모듈 분리 (비파괴, require-based).
27567
- const { _PROVIDER_MODEL_CATALOG, _AGENT_ROLE_PROMPTS, ROLE_CATALOG, _ROLE_ALIASES } = require('../lib/role-catalog');
28175
+ const { _PROVIDER_MODEL_CATALOG, _AGENT_ROLE_PROMPTS, ROLE_CATALOG, ROLE_REQUIREMENTS, _ROLE_ALIASES, pickCatalogModel } = require('../lib/role-catalog');
28176
+ const { FALLBACK_POLICIES, validateRoleDefinitionShape, normalizeRoleDefinition, normalizeProviderIdentifier, canonicalProviderIdentity, canonicalLegacyProviderIdentity, isValidModelIdentifier, inferModelFamily, normalizeAvailability, readAvailabilityObservations, availabilityExtrasForCandidate } = require('../lib/role-fallback');
28177
+ const _roleStore = require('../lib/role-store');
27568
28178
  function _normalizeRole(name) {
27569
28179
  const raw = String(name || '').trim();
27570
- if (_ROLE_ALIASES[raw]) return _ROLE_ALIASES[raw];
28180
+ if (_roleStore.hasOwn(_ROLE_ALIASES, raw)) return _ROLE_ALIASES[raw];
27571
28181
  const low = raw.toLowerCase();
27572
- return _ROLE_ALIASES[low] || low;
28182
+ return _roleStore.hasOwn(_ROLE_ALIASES, low) ? _ROLE_ALIASES[low] : low;
27573
28183
  }
28184
+ function _roleCatalogDef(role) { return _roleStore.hasOwn(ROLE_CATALOG, role) ? ROLE_CATALOG[role] : null; }
27574
28185
  // 역할 modelKind 에 맞는 모델 선택 — top(최상위) / code(코드 특화) / fast(빠른). 순수 함수(selftest).
27575
- function _pickModel(provider, kind) {
27576
- const list = _PROVIDER_MODEL_CATALOG[provider] || [];
27577
- if (!list.length) return null;
27578
- if (kind === 'code') { const m = list.find(x => /codex|coder|code/i.test(x.id)); return (m || list[0]).id; }
27579
- if (kind === 'fast') { const m = list.find(x => /haiku|mini|flash|oss/i.test(x.id)); return (m || list[list.length - 1]).id; }
27580
- return list[0].id; // top
27581
- }
27582
- function _rolesFile(root) { return path.join(absRoot(root), '.leerness', 'agent-roles.json'); }
27583
- function _loadRoles(root) {
27584
- const f = _rolesFile(root);
27585
- if (!exists(f)) return {};
27586
- try { const j = JSON.parse(read(f)); return (j && j.roles && typeof j.roles === 'object') ? j.roles : {}; } catch { return {}; }
28186
+ function _pickModel(provider, kind) { return pickCatalogModel(provider, kind); }
28187
+ function _rolesFile(root) { return _roleStore.rolesFile(root); }
28188
+ function _loadRoleState(root) {
28189
+ const state = _roleStore.readRoleStore(root);
28190
+ if (!state.ok) throw new _roleStore.RoleStoreError(state);
28191
+ return state;
28192
+ }
28193
+ function _loadRoles(root) { return _loadRoleState(root).roles; }
28194
+ function _loadRolesChecked(root) {
28195
+ const state = _roleStore.readRoleStore(root);
28196
+ return {
28197
+ ...state,
28198
+ invalid: !state.ok,
28199
+ roles: state.ok ? state.roles : {},
28200
+ reason: state.ok ? null : _roleStore.messageFor(state),
28201
+ schemaVersion: state.ok && state.document ? (state.document.schemaVersion || null) : null,
28202
+ };
27587
28203
  }
27588
28204
  // 1.36.137 (동시성 감사 실측): `roles set/unset/suggest --apply` 는 load→mutate→save 를 **락 없이** 했다 —
27589
28205
  // 4 프로세스만으로 28.8%, 16 프로세스에서 11~15/16 만 살아남았다(exit 0 · 경고 0 · 손상 0).
27590
28206
  // 저장만 잠그면 소용이 없다(읽기와 쓰기 사이가 창이다). 읽기·변경·쓰기를 **한 락 안**에 묶는다 —
27591
28207
  // 1.36.108 이 rules.md 에서 만든 형태 그대로다.
27592
- function _updateRoles(root, mutate) {
27593
- return _withLock(_rolesFile(root), () => {
27594
- const roles = _loadRoles(root);
27595
- // mutate 가 false 를 돌려주면 **쓰지 않는다** — 바꿀 것이 없는데 파일을 건드리지 않는다.
27596
- if (mutate(roles) === false) return null;
27597
- return _saveRoles(root, roles);
27598
- });
28208
+ function _updateRoles(root, mutate) { return _roleStore.updateRoles(root, mutate, _withLock); }
28209
+ function _saveRoles(root, roles, validatedState) { return _roleStore.saveRoles(root, roles, validatedState); }
28210
+ // 역할 → {role, provider, model, persona, source} 해석 (사용자 설정 전용; 미설정 시 null).
28211
+ function _resolveRoleFromState(state, name) {
28212
+ const role = _normalizeRole(name);
28213
+ const roles = state.roles;
28214
+ const def = _roleStore.hasOwn(roles, role) ? roles[role] : null;
28215
+ if (!def || typeof def !== 'object') return null;
28216
+ const normalized = normalizeRoleDefinition(role, def, { allowLegacyProviderIds: true });
28217
+ const primary = normalized.primary;
28218
+ const provider = primary && typeof primary.provider === 'string' ? primary.provider.trim() : '';
28219
+ if (!provider) return null;
28220
+ return {
28221
+ role,
28222
+ provider,
28223
+ model: primary.model || null,
28224
+ modelFamily: primary.modelFamily || inferModelFamily(primary.model),
28225
+ persona: normalized.persona || '',
28226
+ candidates: normalized.candidates,
28227
+ fallbackPolicy: normalized.fallbackPolicy,
28228
+ requirements: normalized.requirements,
28229
+ primary: { provider, model: primary.model || null, modelFamily: primary.modelFamily || inferModelFamily(primary.model) },
28230
+ source: 'user',
28231
+ storeRevision: state.revision,
28232
+ };
27599
28233
  }
27600
- function _saveRoles(root, roles) {
27601
- const f = _rolesFile(root);
27602
- mkdirp(path.dirname(f));
27603
- writeUtf8(f, JSON.stringify({ schemaVersion: 1, updatedAt: new Date().toISOString(), roles }, null, 2) + '\n');
27604
- return f;
28234
+ function _resolveRoles(root, names) {
28235
+ const state = _loadRoleState(root);
28236
+ return (names || []).map(name => _resolveRoleFromState(state, name));
27605
28237
  }
27606
- // 역할 → {role, provider, model, persona, source} 해석 (사용자 설정 전용; 미설정 시 null).
27607
28238
  function _resolveRole(root, name) {
27608
- const role = _normalizeRole(name);
27609
- const roles = _loadRoles(root);
27610
- if (roles[role] && roles[role].provider) return { role, provider: roles[role].provider, model: roles[role].model || null, persona: roles[role].persona || '', source: 'user' };
27611
- return null;
28239
+ return _resolveRoles(root, [name])[0];
28240
+ }
28241
+ function _emitRoleStoreFailure(json, state, language = 'ko') {
28242
+ const payload = { ok: false, ..._roleStore.publicError(state) };
28243
+ if (json) { log(JSON.stringify(payload, null, 2)); process.exitCode = 1; return payload; }
28244
+ fail(_roleStore.messageFor(state, language));
28245
+ for (const problem of payload.problems.slice(0, 5)) log(` - ${problem.code}: ${problem.path}`);
28246
+ log(language === 'en'
28247
+ ? ' After recovery: leerness roles validate --path .'
28248
+ : ' 복구 후 확인: leerness roles validate --path .');
28249
+ return payload;
28250
+ }
28251
+ function _guardRoleStore(json, action) {
28252
+ try { return { ok: true, value: action() }; }
28253
+ catch (error) {
28254
+ if (error instanceof _roleStore.RoleStoreError) {
28255
+ _emitRoleStoreFailure(json, error.roleStoreState, _uiLang(process.cwd()));
28256
+ return { ok: false, value: null };
28257
+ }
28258
+ throw error;
28259
+ }
28260
+ }
28261
+ function _validateRolesStoreCmd(root, json) {
28262
+ const language = _uiLang(root);
28263
+ const en = language === 'en';
28264
+ const result = _roleStore.validationSummary(root);
28265
+ if (!result.ok) { _emitRoleStoreFailure(json, result, language); return; }
28266
+ if (json) { log(JSON.stringify(result, null, 2)); return result; }
28267
+ log('# leerness roles validate');
28268
+ log(en
28269
+ ? ` state: ${result.state} · roles ${result.legacy.roleCount} · file ${result.fileExists ? result.bytes + ' bytes' : 'absent'}`
28270
+ : ` 상태: ${result.state} · 역할 ${result.legacy.roleCount}개 · 파일 ${result.fileExists ? result.bytes + ' bytes' : '없음'}`);
28271
+ log(en
28272
+ ? ` v2 preview projection: Agents ${result.projection.agentCount} · pipelines ${result.projection.pipelineCount} · assignmentReady=${result.projection.assignmentReady}`
28273
+ : ` v2 미리보기 projection: Agent ${result.projection.agentCount}개 · pipeline ${result.projection.pipelineCount}개 · assignmentReady=${result.projection.assignmentReady}`);
28274
+ log(en
28275
+ ? ' This command did not migrate files or enable v2 runtime routing.'
28276
+ : ' 이 명령은 파일을 마이그레이션하거나 v2 런타임 라우팅을 활성화하지 않았습니다.');
28277
+ log(en
28278
+ ? ' Native role-definitions/agent-instances/routing-policy files were not read; this is a legacy projection preview.'
28279
+ : ' native role-definitions/agent-instances/routing-policy 파일은 읽지 않았으며 legacy 투영 미리보기입니다.');
28280
+ log(en
28281
+ ? ' Provider readiness, authentication, entitlement, quota, and live callability were not checked.'
28282
+ : ' provider 준비·인증·모델 권한·quota·실제 호출 가능성은 확인하지 않았습니다.');
28283
+ return result;
27612
28284
  }
27613
28285
  // 활성(ready) 에이전트 기반 최적 역할 배치 제안 — "올바른 방향성 판단" (사용자 요청).
27614
28286
  function _suggestRoles(root) {
27615
- const ready = EXTERNAL_AGENTS.map(a => ({ id: a.id, status: _checkAgent(a) })).filter(x => x.status.status === 'ready').map(x => x.id);
28287
+ const availabilityState = readAvailabilityObservations(root, 2000);
28288
+ if (availabilityState.ok === false || availabilityState.partial) {
28289
+ return { ready: [], suggestions: [], error: { code: availabilityState.code || 'availability_history_partial', message: availabilityState.error || 'availability observation history is incomplete' } };
28290
+ }
28291
+ const providers = _allProviders(root);
28292
+ const inspected = providers.map(definition => {
28293
+ let raw;
28294
+ try { raw = _checkAgent(definition); }
28295
+ catch (error) { raw = { status: 'check-error', error: error && error.message }; }
28296
+ const availability = normalizeAvailability(definition, raw, {
28297
+ provider: definition.id,
28298
+ ...availabilityExtrasForCandidate(availabilityState, { provider: definition.id, model: null }),
28299
+ });
28300
+ return { id: definition.id, definition, availability };
28301
+ });
28302
+ const eligible = inspected.filter(item => item.availability.eligible);
28303
+ const ready = eligible.map(item => item.id);
27616
28304
  const readySet = new Set(ready);
28305
+ const providerOrder = (def) => [...new Set([...(def.prefer || []), ...ready])].filter(id => readySet.has(id));
28306
+ const coderDef = ROLE_CATALOG.coder || { prefer: [] };
28307
+ const coderProvider = providerOrder(coderDef)[0] || null;
27617
28308
  const suggestions = [];
27618
28309
  for (const [role, def] of Object.entries(ROLE_CATALOG)) {
27619
- const provider = def.prefer.find(p => readySet.has(p)) || null;
27620
- const model = provider ? _pickModel(provider, def.modelKind) : null;
27621
- suggestions.push({ role, ko: def.ko, desc: def.desc, provider, model, ready: !!provider, why: def.why, prefer: def.prefer });
28310
+ let ordered = providerOrder(def);
28311
+ if (role === 'reviewer' && coderProvider && ordered.some(id => id !== coderProvider)) {
28312
+ ordered = ordered.filter(id => id !== coderProvider).concat(ordered.filter(id => id === coderProvider));
28313
+ }
28314
+ const provider = role === 'coder' && coderProvider ? coderProvider : (ordered[0] || null);
28315
+ const availability = provider ? inspected.find(item => item.id === provider).availability : null;
28316
+ const candidates = ordered.filter(id => id !== provider).map(id => ({ provider: id, model: null, modelFamily: null }));
28317
+ suggestions.push({
28318
+ role, ko: def.ko, desc: def.desc, provider, model: null, modelFamily: null,
28319
+ ready: !!provider, candidates, availability,
28320
+ modelVerified: false,
28321
+ why: provider ? `${def.why} · provider 실행 준비만 확인; 실제 모델 ID/권한/쿼터는 미검증` : def.why,
28322
+ prefer: def.prefer,
28323
+ });
28324
+ }
28325
+ return { ready, inspected: inspected.map(item => ({ id: item.id, availability: item.availability })), suggestions, availabilityObservationState: { activeCount: availabilityState.activeCount, partial: availabilityState.partial } };
28326
+ }
28327
+ function _roleFlagValues(argv, name) {
28328
+ const list = Array.isArray(argv) ? argv : [];
28329
+ const out = [];
28330
+ for (let i = 0; i < list.length; i++) {
28331
+ const token = String(list[i] || '');
28332
+ if (token === name && i + 1 < list.length && !String(list[i + 1]).startsWith('-')) out.push(String(list[++i]));
28333
+ else if (token.startsWith(name + '=')) out.push(token.slice(name.length + 1));
27622
28334
  }
27623
- return { ready, suggestions };
28335
+ return out;
27624
28336
  }
27625
- // leerness roles <list|set|unset|catalog|suggest|verify>
28337
+ function _parseRoleCandidateSpec(value, family) {
28338
+ const raw = String(value || '').trim();
28339
+ const at = raw.indexOf(':');
28340
+ const provider = canonicalProviderIdentity(at >= 0 ? raw.slice(0, at) : raw);
28341
+ const model = (at >= 0 ? raw.slice(at + 1) : '').trim();
28342
+ if (!provider) return null;
28343
+ return { provider, model: model || null, modelFamily: String(family || '').trim().toLowerCase() || inferModelFamily(model) };
28344
+ }
28345
+ function _roleCandidatesFromFlags(argv) {
28346
+ const specs = _roleFlagValues(argv, '--candidate');
28347
+ const families = _roleFlagValues(argv, '--candidate-family');
28348
+ if (families.length > specs.length) return { error: '--candidate-family 수가 --candidate 수보다 많습니다' };
28349
+ const candidates = specs.map((v, i) => _parseRoleCandidateSpec(v, families[i])).filter(Boolean);
28350
+ const removals = _roleFlagValues(argv, '--remove-candidate').map(v => _parseRoleCandidateSpec(v, null)).filter(Boolean);
28351
+ if (candidates.length !== specs.length || removals.length !== _roleFlagValues(argv, '--remove-candidate').length) {
28352
+ return { error: 'candidate provider는 정규 ASCII provider ID여야 합니다' };
28353
+ }
28354
+ return { candidates, removals, touched: specs.length > 0 || removals.length > 0 || argv.includes('--clear-candidates') };
28355
+ }
28356
+
28357
+ // leerness roles <list|set|unset|catalog|suggest|verify|validate>
27626
28358
  function rolesCmd(root, sub, ...args) {
27627
28359
  root = absRoot(root || process.cwd());
27628
28360
  // 1.36.140 (재검수 P3, 재현): 오타 난 `--path` 로 `roles unset` 하면 **실패하면서 그 경로와 `.leerness/` 를 만들었다**.
@@ -27635,9 +28367,17 @@ function rolesCmd(root, sub, ...args) {
27635
28367
  failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
27636
28368
  return true;
27637
28369
  };
27638
- try { _loadEnvFile(root); _loadEnvFile(path.join(root, '..')); } catch {}
27639
28370
  const json = has('--json');
27640
- sub = sub || 'list';
28371
+ const _storeFailure = (error) => {
28372
+ if (error instanceof _roleStore.RoleStoreError) {
28373
+ _emitRoleStoreFailure(json, error.roleStoreState, _uiLang(root));
28374
+ return;
28375
+ }
28376
+ failJson(json, (error && error.code) || 'role_store_error', (error && error.message) || 'agent-roles.json을 읽거나 쓸 수 없습니다');
28377
+ };
28378
+ sub = String(sub || 'list').trim().toLowerCase();
28379
+
28380
+ if (sub === 'validate') return _validateRolesStoreCmd(root, json);
27641
28381
 
27642
28382
  if (sub === 'catalog') {
27643
28383
  if (json) { log(JSON.stringify({ roles: ROLE_CATALOG }, null, 2)); return; }
@@ -27646,29 +28386,113 @@ function rolesCmd(root, sub, ...args) {
27646
28386
  log(` ${role.padEnd(11)} ${d.ko.padEnd(8)} — ${d.desc}`);
27647
28387
  log(` 선호 provider: ${d.prefer.join(' > ')} · 모델 등급: ${d.modelKind} · 근거: ${d.why}`);
27648
28388
  }
27649
- log(`\n 설정: leerness roles set <role> --provider <id> [--model <m>] [--persona "..."]`);
28389
+ log(`\n 설정: leerness roles set <role> --provider <id> [--model <m>] [--model-family <f>] [--policy strict|balanced|continuity] [--candidate <provider[:model]> ...]`);
27650
28390
  log(` 자동 제안: leerness roles suggest [--apply]`);
27651
28391
  return;
27652
28392
  }
27653
28393
 
27654
28394
  if (sub === 'set') {
28395
+ const preflight = _guardRoleStore(json, () => _loadRoles(root));
28396
+ if (!preflight.ok) return;
28397
+ try { _loadEnvFile(root); _loadEnvFile(path.join(root, '..')); } catch {}
27655
28398
  const roleRaw = args.find(a => a && !a.startsWith('-'));
27656
28399
  const role = _normalizeRole(roleRaw);
27657
28400
  if (!role) return fail('roles set <role> 필요 (예: coder, reviewer, commander)');
27658
- if (!ROLE_CATALOG[role] && !has('--force')) return fail(`알 수 없는 역할: ${role} (catalog: ${Object.keys(ROLE_CATALOG).join(', ')}) — 커스텀 역할은 --force`);
27659
- const provider = arg('--provider', null) || arg('--to', null);
28401
+ if (!_roleCatalogDef(role) && !has('--force')) return fail(`알 수 없는 역할: ${role} (catalog: ${Object.keys(ROLE_CATALOG).join(', ')}) — 커스텀 역할은 --force`);
28402
+ const providerRaw = arg('--provider', null) || arg('--to', null);
28403
+ const provider = canonicalProviderIdentity(providerRaw);
27660
28404
  if (!provider) return fail('--provider <id> 필요 (claude/codex/agy/grok/copilot/ollama)');
27661
28405
  if (!_allProviders(root).some(p => p.id === provider) && !has('--force')) return fail(`알 수 없는 provider: ${provider} — 커스텀은 --force 또는 provider add`);
27662
- let model = arg('--model', null);
27663
- if (!model) model = _pickModel(provider, (ROLE_CATALOG[role] || {}).modelKind || 'top');
27664
- const persona = arg('--persona', null) || (ROLE_CATALOG[role] ? ROLE_CATALOG[role].desc : '');
28406
+ const model = arg('--model', null) || null;
28407
+ const modelFamily = String(arg('--model-family', '') || '').trim().toLowerCase() || inferModelFamily(model);
28408
+ const requestedPolicy = arg('--policy', null) || arg('--fallback-policy', null);
28409
+ if (requestedPolicy && !FALLBACK_POLICIES.includes(String(requestedPolicy).toLowerCase())) return fail(`--policy 는 ${FALLBACK_POLICIES.join('|')} (받음: ${requestedPolicy})`);
28410
+ const rawRoleArgv = process.argv.slice(2);
28411
+ const parsedCandidates = _roleCandidatesFromFlags(rawRoleArgv);
28412
+ if (parsedCandidates.error) return fail(parsedCandidates.error);
28413
+ const allProviders = _allProviders(root);
28414
+ const knownProviders = new Set(allProviders.map(p => p.id));
28415
+ const unknownCandidates = parsedCandidates.candidates.concat(parsedCandidates.removals).filter(c => !knownProviders.has(c.provider));
28416
+ if (unknownCandidates.length && !has('--force')) return fail(`알 수 없는 candidate provider: ${[...new Set(unknownCandidates.map(c => c.provider))].join(', ')} — provider add 또는 --force`);
28417
+ const persona = arg('--persona', null) || (_roleCatalogDef(role) ? _roleCatalogDef(role).desc : '');
27665
28418
  if (_requireExistingPath()) return;
27666
- const f = _updateRoles(root, (roles) => { roles[role] = { provider, model: model || null, persona }; });
27667
- // 비활성 provider 경고 (verify 사전 노출)
28419
+ let f, savedRole;
28420
+ try {
28421
+ f = _updateRoles(root, (roles) => {
28422
+ const previous = _roleStore.hasOwn(roles, role) && roles[role] && typeof roles[role] === 'object' ? roles[role] : {};
28423
+ const previousForUpdate = { ...previous };
28424
+ delete previousForUpdate.policy;
28425
+ const previousNormalized = normalizeRoleDefinition(role, previous, { allowLegacyProviderIds: true });
28426
+ const previousCandidateExtensions = new Map(
28427
+ (Array.isArray(previous.candidates) ? previous.candidates : [])
28428
+ .filter(candidate => candidate && typeof candidate === 'object' && !Array.isArray(candidate))
28429
+ .map(candidate => {
28430
+ const candidateProvider = canonicalProviderIdentity(candidate.provider || candidate.id || '');
28431
+ const candidateModel = candidate.model == null ? '' : String(candidate.model).trim();
28432
+ return [`${candidateProvider || ''}\u0000${candidateModel}`, candidate];
28433
+ }),
28434
+ );
28435
+ let candidates = previousNormalized.candidates.map(candidate => ({
28436
+ provider: candidate.provider,
28437
+ model: candidate.model || null,
28438
+ modelFamily: candidate.modelFamily || null,
28439
+ }));
28440
+ if (has('--clear-candidates')) candidates = [];
28441
+ if (parsedCandidates.candidates.length) candidates = parsedCandidates.candidates;
28442
+ if (parsedCandidates.removals.length) {
28443
+ const removed = new Set(parsedCandidates.removals.map(c => `${c.provider}\u0000${c.model || ''}`));
28444
+ candidates = candidates.filter(c => !removed.has(`${c.provider}\u0000${c.model || ''}`));
28445
+ }
28446
+ const normalized = normalizeRoleDefinition(role, {
28447
+ ...previousForUpdate,
28448
+ primary: { provider, model: model || null, modelFamily },
28449
+ candidates,
28450
+ fallbackPolicy: requestedPolicy || previous.fallbackPolicy || previous.policy || 'balanced',
28451
+ requirements: previous.requirements || ROLE_REQUIREMENTS[role] || {},
28452
+ persona,
28453
+ }, { allowLegacyProviderIds: true });
28454
+ savedRole = {
28455
+ ...previousForUpdate,
28456
+ provider,
28457
+ model: model || null,
28458
+ modelFamily: modelFamily || null,
28459
+ persona,
28460
+ primary: {
28461
+ ...(previous.primary && typeof previous.primary === 'object' && !Array.isArray(previous.primary) ? previous.primary : {}),
28462
+ provider,
28463
+ model: model || null,
28464
+ modelFamily: modelFamily || null,
28465
+ },
28466
+ candidates: normalized.candidates.map(c => ({
28467
+ ...(previousCandidateExtensions.get(`${c.provider}\u0000${c.model || ''}`) || {}),
28468
+ provider: c.provider,
28469
+ model: c.model || null,
28470
+ modelFamily: c.modelFamily || null,
28471
+ })),
28472
+ fallbackPolicy: normalized.fallbackPolicy,
28473
+ requirements: normalized.requirements,
28474
+ };
28475
+ const savedShape = validateRoleDefinitionShape(role, savedRole, { allowLegacyProviderIds: true });
28476
+ if (!savedShape.ok) {
28477
+ const error = new Error(savedShape.error);
28478
+ error.code = savedShape.code || 'role_definition_invalid';
28479
+ error.field = savedShape.field || null;
28480
+ throw error;
28481
+ }
28482
+ _roleStore.defineOwn(roles, role, savedRole);
28483
+ });
28484
+ } catch (error) { _storeFailure(error); return; }
28485
+ // 비활성 provider 경고 — validation과 같은 사용자 provider 정의를 사용한다.
27668
28486
  let warnMsg = '';
27669
- try { const st = _checkAgent(EXTERNAL_AGENTS.find(a => a.id === provider) || { id: provider, bin: provider, versionArgs: ['--version'], envFlag: `LEERNESS_ENABLE_${provider.toUpperCase()}` }); if (st.status !== 'ready') warnMsg = `⚠ ${provider} 현재 비활성(${st.status}) — 활성화: LEERNESS_ENABLE_${provider.toUpperCase()}=1 + CLI 설치`; } catch {}
27670
- if (json) { log(JSON.stringify({ set: role, provider, model: model || null, persona, file: f, warning: warnMsg || null }, null, 2)); return; }
27671
- ok(`역할 설정: ${role} (${ROLE_CATALOG[role] ? ROLE_CATALOG[role].ko : 'custom'}) → ${provider}${model ? ' / ' + model : ''}`);
28487
+ try {
28488
+ const providerDef = allProviders.find(a => a.id === provider);
28489
+ const st = providerDef ? _checkAgent(providerDef) : { status: 'unknown-provider' };
28490
+ if (st.status !== 'ready') warnMsg = `⚠ ${provider} 현재 비활성(${st.status})${providerDef && providerDef.envFlag ? ` — 활성화: ${providerDef.envFlag}=1 + CLI 설치` : ''}`;
28491
+ } catch {}
28492
+ if (json) { log(JSON.stringify({ set: role, provider, model: model || null, modelFamily: modelFamily || null, persona, fallbackPolicy: savedRole.fallbackPolicy, candidates: savedRole.candidates, file: f, warning: warnMsg || null }, null, 2)); return; }
28493
+ ok(`역할 설정: ${role} (${_roleCatalogDef(role) ? _roleCatalogDef(role).ko : 'custom'}) → ${provider}${model ? ' / ' + model : ''}${modelFamily ? ` [family=${modelFamily}]` : ''}`);
28494
+ log(` policy: ${savedRole.fallbackPolicy} · candidates: ${savedRole.candidates.length}`);
28495
+ savedRole.candidates.forEach((c, i) => log(` ${i + 1}. ${c.provider}${c.model ? ' / ' + c.model : ''}${c.modelFamily ? ` [family=${c.modelFamily}]` : ''}`));
27672
28496
  if (persona) log(` persona: ${persona}`);
27673
28497
  if (warnMsg) warn(warnMsg);
27674
28498
  log(` 파일: ${f} · 사용: leerness agents dispatch "<task>" --role ${role}`);
@@ -27682,10 +28506,13 @@ function rolesCmd(root, sub, ...args) {
27682
28506
  // 사유는 바깥의 `_unsetErr` 로 전달한다(락 안에서 출력하지 않는다).
27683
28507
  if (_requireExistingPath()) return;
27684
28508
  let _unsetErr = null;
27685
- const f = _updateRoles(root, (roles) => {
27686
- if (!roles[role]) { _unsetErr = `설정되지 않은 역할: ${role}`; return false; }
27687
- delete roles[role];
27688
- });
28509
+ let f;
28510
+ try {
28511
+ f = _updateRoles(root, (roles) => {
28512
+ if (!_roleStore.hasOwn(roles, role)) { _unsetErr = `설정되지 않은 역할: ${role}`; return false; }
28513
+ delete roles[role];
28514
+ });
28515
+ } catch (error) { _storeFailure(error); return; }
27689
28516
  if (_unsetErr) return fail(_unsetErr);
27690
28517
  if (json) { log(JSON.stringify({ removed: role, file: f }, null, 2)); return; }
27691
28518
  ok(`역할 제거: ${role}`);
@@ -27693,28 +28520,58 @@ function rolesCmd(root, sub, ...args) {
27693
28520
  }
27694
28521
 
27695
28522
  if (sub === 'suggest') {
27696
- const { ready, suggestions } = _suggestRoles(root);
28523
+ const preflight = _guardRoleStore(json, () => _loadRoles(root));
28524
+ if (!preflight.ok) return;
28525
+ try { _loadEnvFile(root); _loadEnvFile(path.join(root, '..')); } catch {}
28526
+ const suggestionResult = _suggestRoles(root);
28527
+ if (suggestionResult.error) { failJson(json, suggestionResult.error.code, suggestionResult.error.message); return; }
28528
+ const { ready, suggestions } = suggestionResult;
27697
28529
  if (has('--apply')) {
27698
28530
  if (_requireExistingPath()) return;
27699
28531
  let applied = 0;
27700
- const f = _updateRoles(root, (roles) => {
27701
- applied = 0;
27702
- for (const s of suggestions) { if (s.ready) { roles[s.role] = { provider: s.provider, model: s.model, persona: ROLE_CATALOG[s.role].desc }; applied++; } }
27703
- });
27704
- if (json) { log(JSON.stringify({ applied, ready, file: f, suggestions }, null, 2)); return; }
28532
+ let f;
28533
+ try {
28534
+ f = _updateRoles(root, (roles) => {
28535
+ applied = 0;
28536
+ for (const s of suggestions) {
28537
+ if (!s.ready) continue;
28538
+ const previous = _roleStore.hasOwn(roles, s.role) && roles[s.role] && typeof roles[s.role] === 'object' ? roles[s.role] : {};
28539
+ const candidates = Array.isArray(previous.candidates) && previous.candidates.length ? previous.candidates : s.candidates;
28540
+ _roleStore.defineOwn(roles, s.role, {
28541
+ ...previous,
28542
+ provider: s.provider,
28543
+ model: null,
28544
+ modelFamily: null,
28545
+ persona: ROLE_CATALOG[s.role].desc,
28546
+ primary: {
28547
+ ...(previous.primary && typeof previous.primary === 'object' && !Array.isArray(previous.primary)
28548
+ ? previous.primary : {}),
28549
+ provider: s.provider,
28550
+ model: null,
28551
+ modelFamily: null,
28552
+ },
28553
+ candidates,
28554
+ fallbackPolicy: previous.fallbackPolicy || 'balanced',
28555
+ requirements: previous.requirements || ROLE_REQUIREMENTS[s.role] || {},
28556
+ });
28557
+ applied++;
28558
+ }
28559
+ });
28560
+ } catch (error) { _storeFailure(error); return; }
28561
+ if (json) { log(JSON.stringify({ applied, ready, file: f, suggestions, availabilityObservationState: suggestionResult.availabilityObservationState }, null, 2)); return; }
27705
28562
  ok(`제안 적용: ${applied}개 역할 → ${f}`);
27706
- for (const s of suggestions.filter(x => x.ready)) log(` ${s.role.padEnd(11)} → ${s.provider} / ${s.model}`);
28563
+ for (const s of suggestions.filter(x => x.ready)) log(` ${s.role.padEnd(11)} → ${s.provider} / (provider default; model unverified)`);
27707
28564
  const unready = suggestions.filter(x => !x.ready);
27708
28565
  if (unready.length) log(` (미배정 ${unready.length}: ${unready.map(x => x.role).join(', ')} — 활성 provider 없음)`);
27709
28566
  return;
27710
28567
  }
27711
- if (json) { log(JSON.stringify({ ready, suggestions }, null, 2)); return; }
28568
+ if (json) { log(JSON.stringify({ ready, suggestions, inspected: suggestionResult.inspected, availabilityObservationState: suggestionResult.availabilityObservationState }, null, 2)); return; }
27712
28569
  log(`# leerness roles suggest (1.9.270) — 활성 에이전트 기반 최적 역할 배치`);
27713
28570
  log(`활성(ready): ${ready.join(', ') || '(없음 — LEERNESS_ENABLE_* + CLI 설치 필요)'}`);
27714
28571
  log('');
27715
28572
  for (const s of suggestions) {
27716
28573
  const mark = s.ready ? '✓' : '·';
27717
- log(` ${mark} ${s.role.padEnd(11)} ${s.ko.padEnd(8)} → ${s.ready ? `${s.provider} / ${s.model}` : '(미배정 — 선호 ' + s.prefer.join('>') + ' 비활성)'}`);
28574
+ log(` ${mark} ${s.role.padEnd(11)} ${s.ko.padEnd(8)} → ${s.ready ? `${s.provider} / (provider default; model unverified)` : '(미배정 — 선호 ' + s.prefer.join('>') + ' 비활성)'}`);
27718
28575
  log(` 근거: ${s.why}`);
27719
28576
  }
27720
28577
  log(`\n 적용: leerness roles suggest --apply · 개별: leerness roles set <role> --provider <id>`);
@@ -27722,39 +28579,111 @@ function rolesCmd(root, sub, ...args) {
27722
28579
  }
27723
28580
 
27724
28581
  if (sub === 'verify') {
27725
- const roles = _loadRoles(root);
28582
+ const loaded = _guardRoleStore(json, () => _loadRoleState(root));
28583
+ if (!loaded.ok) return;
28584
+ try { _loadEnvFile(root); _loadEnvFile(path.join(root, '..')); } catch {}
28585
+ const roleState = loaded.value;
28586
+ const roles = roleState.roles;
27726
28587
  const entries = Object.entries(roles);
27727
- const results = entries.map(([role, def]) => {
27728
- let status = 'unknown';
27729
- try { const a = EXTERNAL_AGENTS.find(x => x.id === def.provider); status = a ? _checkAgent(a).status : 'unknown-provider'; } catch {}
27730
- return { role, provider: def.provider, model: def.model || null, status, ok: status === 'ready' };
28588
+ const availabilityState = readAvailabilityObservations(root, 2000);
28589
+ if (availabilityState.ok === false || availabilityState.partial) {
28590
+ failJson(json, availabilityState.code || 'availability_history_partial', availabilityState.error || 'availability observation history is incomplete');
28591
+ return;
28592
+ }
28593
+ const allProviders = _allProviders(root);
28594
+ const providerMap = new Map(allProviders.map(p => [p.id, p]));
28595
+ const inspect = (candidate) => {
28596
+ const def = candidate && providerMap.get(candidate.provider);
28597
+ let raw = { status: def ? 'unknown' : 'unknown-provider' };
28598
+ try { if (def) raw = _checkAgent(def); } catch (error) { raw = { status: 'check-error', error: error && error.message }; }
28599
+ const availability = normalizeAvailability(def || null, raw, {
28600
+ provider: candidate && candidate.provider,
28601
+ ...availabilityExtrasForCandidate(availabilityState, candidate || {}),
28602
+ });
28603
+ return {
28604
+ provider: candidate && candidate.provider || null,
28605
+ model: candidate && candidate.model || null,
28606
+ modelFamily: candidate && candidate.modelFamily || inferModelFamily(candidate && candidate.model),
28607
+ status: availability.status,
28608
+ ready: availability.eligible,
28609
+ availability,
28610
+ };
28611
+ };
28612
+ const results = entries.map(([role]) => {
28613
+ const resolved = _resolveRoleFromState(roleState, role);
28614
+ const primary = inspect(resolved && resolved.primary);
28615
+ const candidateResults = resolved ? resolved.candidates.map(inspect) : [];
28616
+ const fallbackReady = !!(resolved && resolved.fallbackPolicy !== 'strict' && candidateResults.some(c => c.ready));
28617
+ const routable = primary.ready || fallbackReady;
28618
+ return {
28619
+ role,
28620
+ provider: primary.provider,
28621
+ model: primary.model,
28622
+ modelFamily: resolved && resolved.modelFamily || null,
28623
+ fallbackPolicy: resolved ? resolved.fallbackPolicy : null,
28624
+ status: primary.status,
28625
+ primaryReady: primary.ready,
28626
+ fallbackReady,
28627
+ routable,
28628
+ availability: primary.availability,
28629
+ candidates: candidateResults,
28630
+ };
27731
28631
  });
27732
- const bad = results.filter(r => !r.ok);
27733
- if (json) { log(JSON.stringify({ total: entries.length, ready: results.filter(r => r.ok).length, issues: bad, results }, null, 2)); return; }
27734
- log(`# leerness roles verify (1.9.270)`);
28632
+ const bad = results.filter(r => !r.routable);
28633
+ const degraded = results.filter(r => !r.primaryReady && r.routable);
28634
+ if (json) {
28635
+ log(JSON.stringify({
28636
+ total: entries.length,
28637
+ primaryReady: results.filter(r => r.primaryReady).length,
28638
+ routable: results.filter(r => r.routable).length,
28639
+ degraded: degraded.length,
28640
+ issues: bad,
28641
+ availabilityObservationState: { activeCount: availabilityState.activeCount, partial: availabilityState.partial },
28642
+ results,
28643
+ }, null, 2));
28644
+ if (bad.length) process.exitCode = 1;
28645
+ return;
28646
+ }
28647
+ log(`# leerness roles verify (1.9.270/T-0172)`);
27735
28648
  if (!entries.length) { log(' (설정된 역할 없음 — leerness roles suggest --apply 또는 roles set)'); return; }
27736
- for (const r of results) log(` ${r.ok ? '🟢' : '🔴'} ${r.role.padEnd(11)} → ${r.provider}${r.model ? ' / ' + r.model : ''} [${r.status}]`);
27737
- if (bad.length) { log(`\n ⚠ ${bad.length}개 역할이 비활성 provider 배정 — 활성화 또는 roles set 으로 재배정`); process.exitCode = 1; }
27738
- else log(`\n ✓ 모든 역할이 활성 provider 에 배정됨`);
28649
+ for (const r of results) {
28650
+ const mark = r.primaryReady ? '🟢' : r.routable ? '🟡' : '🔴';
28651
+ const state = r.primaryReady ? 'primary ready' : r.routable ? 'fallback ready' : 'unroutable';
28652
+ log(` ${mark} ${r.role.padEnd(11)} → ${r.provider}${r.model ? ' / ' + r.model : ''} [${r.status}; ${state}; policy=${r.fallbackPolicy}]`);
28653
+ r.candidates.forEach((c, i) => log(` ${i + 1}. ${c.ready ? '✓' : '×'} ${c.provider}${c.model ? ' / ' + c.model : ''} [${c.status}]`));
28654
+ }
28655
+ if (bad.length) { log(`\n ⚠ ${bad.length}개 역할은 primary와 허용된 fallback 모두 사용할 수 없습니다`); process.exitCode = 1; }
28656
+ else if (degraded.length) log(`\n ⚠ ${degraded.length}개 역할은 primary가 비활성이지만 fallback으로 라우팅 가능합니다`);
28657
+ else log(`\n ✓ 모든 역할의 primary가 활성 provider에 배정됨`);
27739
28658
  return;
27740
28659
  }
27741
28660
 
27742
28661
  // default: list
27743
- const roles = _loadRoles(root);
28662
+ const loaded = _guardRoleStore(json, () => _loadRoleState(root));
28663
+ if (!loaded.ok) return;
28664
+ const roleState = loaded.value;
28665
+ const roles = roleState.roles;
27744
28666
  const entries = Object.entries(roles);
27745
- if (json) { log(JSON.stringify({ roles, count: entries.length }, null, 2)); return; }
28667
+ if (json) {
28668
+ const configurations = entries.map(([role]) => _resolveRoleFromState(roleState, role)).filter(Boolean);
28669
+ log(JSON.stringify({ roles, configurations, count: entries.length }, null, 2)); return;
28670
+ }
27746
28671
  log(`# leerness roles (1.9.270) — 설정된 역할 ${entries.length}개`);
27747
28672
  if (!entries.length) {
27748
28673
  log(` (없음) — leerness roles suggest 로 활성 에이전트 기반 제안 확인 → roles suggest --apply`);
27749
- log(` 또는: leerness roles set coder --provider codex --model gpt-5.5`);
28674
+ log(` 또는: leerness roles set coder --provider codex [--model <verified-model-id>]`);
27750
28675
  return;
27751
28676
  }
27752
28677
  for (const [role, def] of entries) {
27753
- const ko = ROLE_CATALOG[role] ? ROLE_CATALOG[role].ko : 'custom';
27754
- log(` ${role.padEnd(11)} ${ko.padEnd(8)} → ${def.provider}${def.model ? ' / ' + def.model : ''}`);
28678
+ const ko = _roleCatalogDef(role) ? _roleCatalogDef(role).ko : 'custom';
28679
+ const resolved = _resolveRoleFromState(roleState, role);
28680
+ const provider = resolved ? resolved.provider : '(invalid)';
28681
+ const model = resolved ? resolved.model : null;
28682
+ log(` ${role.padEnd(11)} ${ko.padEnd(8)} → ${provider}${model ? ' / ' + model : ''}${resolved && resolved.modelFamily ? ` [family=${resolved.modelFamily}]` : ''}${resolved ? ` [policy=${resolved.fallbackPolicy} · candidates=${resolved.candidates.length}]` : ''}`);
28683
+ if (resolved) resolved.candidates.forEach((c, i) => log(` ${i + 1}. ${c.provider}${c.model ? ' / ' + c.model : ''}${c.modelFamily ? ` [family=${c.modelFamily}]` : ''}`));
27755
28684
  if (def.persona) log(` ${def.persona}`);
27756
28685
  }
27757
- log(`\n 사용: leerness agents dispatch "<task>" --role <role> · 검증: leerness roles verify`);
28686
+ log(`\n 사용: leerness agents dispatch "<task>" --role <role> · 저장소 검증: leerness roles validate · provider 확인: leerness roles verify`);
27758
28687
  }
27759
28688
 
27760
28689
  // ===== 1.9.272: Capability / Security Surface 공개 (GPT-5.5 외부 리뷰 반영) =====
@@ -30892,6 +31821,7 @@ SECURITY & HYGIENE
30892
31821
  HANDOFF & SESSION
30893
31822
  handoff [path] [--compact] [--all-apps] [--json] [--writeback] Session context; tracked writeback is explicit
30894
31823
  session close [path] Closing report + auto handoff
31824
+ lease acquire|release|list|check [file|lease-id] [--session KEY] [--ttl SEC] Exact-file opt-in coordination lease
30895
31825
  context [path] [--json] Agent onboarding context
30896
31826
  retro | insights [path] [--json] [--limit N|--all] Retrospective / cumulative stats (JSON rows: newest 30 by default; rowsTotal = lifetime count, not the --days window)
30897
31827
 
@@ -30941,8 +31871,9 @@ Docs: https://www.npmjs.com/package/leerness | https://leerness.pages.dev
30941
31871
  function help() {
30942
31872
  // 1.23.1 (UR-0010 Phase 6): 영어 opt-in 시 큐레이트 영어판. 기본(ko) 은 아래 한국어 help 그대로.
30943
31873
  if (_uiLang(arg('--path', process.cwd())) === 'en') { _helpEn(); return; }
30944
- log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 1.9.30/31 외부 AI CLI 가용성 + quota 추정 (claude/codex/agy/copilot)\n leerness agents dispatch "<task>" --to <id> # 1.9.30 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness agents multi "<task>" [--only c1,c2] [--write] [--execute] [--timeout 60] # 1.9.152/156 활성 N개 일괄 dispatch (--execute: 실 spawn + consensus)\n leerness provider list|add|remove [args] # 1.9.157 Provider Registry — 사용자 정의 CLI provider 동적 추가 (OpenRouter/Bedrock 흡수)\n leerness agents dispatch "<task>" --multi # 1.9.152 multi 모드 alias (또는 --to all)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness init [path] [--no-stale-check] # 1.9.33 npx 캐시 함정 — 옛 버전 자동 경고 (끄려면 --no-stale-check)\n leerness which [--json] # 1.9.164 진단: 현재 실행 경로/버전 + npm 캐시 + PATH 후보 (구버전 충돌 해결)\n leerness selftest [--json] # 1.9.258 코어 함수 무결성 자가 검증 (설치 손상/부분설치 감지, CI 친화 exit 1)\n leerness shell-guard "<command>" [--json] # 1.9.260 터미널 명령 셸 호환성 린터 (PowerShell 5.1 && 미지원 등 실행 전 감지, UR-0020)\n leerness shell-guard --record --cmd "..." --exit N # 1.9.260 실패한 터미널 명령 기록 → 다음 분석 시 회수\n leerness path-setup [--apply] [--json] # 1.9.254 leerness CLI PATH 자동 등록 (npm global bin 미등록 시)\n leerness web check|screenshot|extract <url> [--out file.png] [--selector "css"] # 1.9.165 playwright bridge (opt-in: npm i -g playwright + permissions.browser)\n leerness pc check|click|type|screenshot [--x N --y N] [--text "s"] [--out f.png] # 1.9.166 robotjs/nut-tree bridge (opt-in: npm i -g robotjs + permissions.mouse/keyboard, ⚠ full 모드 권장)\n leerness lsp check|symbols|references <file/name> [--in dir] [--json] # 1.9.167 LSP 어댑터 MVP (typescript opt-in + regex fallback, 코드 인텔리전스)\n leerness review-request "<request>" [--json] # 1.9.176 사용자 요청 사전 검토 (충돌/재사용/효율/권장 단계 — 사용자 명시)\n leerness contract verify <spec.md> <impl.js> [--json] # 1.9.35 명세 ↔ 구현 일치 검사 (함수/필드)\n leerness reuse autodetect [path] [--apply] [--json] # 1.9.35 src/*.js의 module.exports → reuse-map 후보 등록\n leerness audit [path] [--fix] # 1.9.35 --fix: session-handoff/current-state 자동 갱신\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합
31874
+ log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 외부 AI CLI 다축 가용성 확인\n leerness agents resolve \"<task>\" --role <role> [--preset strict|balanced|continuity] # 역할 유지 + 대체 선택지\n leerness agents fallback provider|session|direct|hold \"<task>\" --role <role> [--session-provider P --session-model M --session-model-family F] # 명시 선택·기록\n leerness agents record completed|failed|reviewed|validated \"<summary>\" [--task T-ID --role R --to P --model M --model-family F --agent P --agent-model M --agent-model-family F --evidence E]\n leerness agents history [--limit 20] # 실행자·검수자·결과 provenance\n leerness agents dispatch \"<task>\" --to <id> # 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness agents multi "<task>" [--only c1,c2] [--write] [--execute] [--timeout 60] # 1.9.152/156 활성 N개 일괄 dispatch (--execute: 실 spawn + consensus)\n leerness provider list|add|remove [args] # 1.9.157 Provider Registry — 사용자 정의 CLI provider 동적 추가 (OpenRouter/Bedrock 흡수)\n leerness agents dispatch "<task>" --multi # 1.9.152 multi 모드 alias (또는 --to all)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness init [path] [--no-stale-check] # 1.9.33 npx 캐시 함정 — 옛 버전 자동 경고 (끄려면 --no-stale-check)\n leerness which [--json] # 1.9.164 진단: 현재 실행 경로/버전 + npm 캐시 + PATH 후보 (구버전 충돌 해결)\n leerness selftest [--json] # 1.9.258 코어 함수 무결성 자가 검증 (설치 손상/부분설치 감지, CI 친화 exit 1)\n leerness shell-guard "<command>" [--json] # 1.9.260 터미널 명령 셸 호환성 린터 (PowerShell 5.1 && 미지원 등 실행 전 감지, UR-0020)\n leerness shell-guard --record --cmd "..." --exit N # 1.9.260 실패한 터미널 명령 기록 → 다음 분석 시 회수\n leerness path-setup [--apply] [--json] # 1.9.254 leerness CLI PATH 자동 등록 (npm global bin 미등록 시)\n leerness web check|screenshot|extract <url> [--out file.png] [--selector "css"] # 1.9.165 playwright bridge (opt-in: npm i -g playwright + permissions.browser)\n leerness pc check|click|type|screenshot [--x N --y N] [--text "s"] [--out f.png] # 1.9.166 robotjs/nut-tree bridge (opt-in: npm i -g robotjs + permissions.mouse/keyboard, ⚠ full 모드 권장)\n leerness lsp check|symbols|references <file/name> [--in dir] [--json] # 1.9.167 LSP 어댑터 MVP (typescript opt-in + regex fallback, 코드 인텔리전스)\n leerness review-request "<request>" [--json] # 1.9.176 사용자 요청 사전 검토 (충돌/재사용/효율/권장 단계 — 사용자 명시)\n leerness contract verify <spec.md> <impl.js> [--json] # 1.9.35 명세 ↔ 구현 일치 검사 (함수/필드)\n leerness reuse autodetect [path] [--apply] [--json] # 1.9.35 src/*.js의 module.exports → reuse-map 후보 등록\n leerness audit [path] [--fix] # 1.9.35 --fix: session-handoff/current-state 자동 갱신\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합
30945
31875
  leerness lens [${_lensDomainList()}] [--json] # 1.18.3/1.36.97 분야별 자기질문 품질 렌즈 (database·contract·recovery·observability 심화 · axes 8축 경량 — 완료 선언 전 자가 점검)\n leerness library [show|page] [path] [--json] [--ai] # 1.36.98 재사용 인벤토리 — 컴포넌트·디자인 토큰 추출 (page: 오프라인 HTML · --ai: 에이전트용 압축)\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
31876
+ leerness lease acquire|release|list|check [file|lease-id] [--session KEY] [--ttl SEC] [--json] # exact-file opt-in 협업 lease
30946
31877
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
30947
31878
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
30948
31879
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -30976,7 +31907,7 @@ async function main() {
30976
31907
  }
30977
31908
  if (has('--help') || has('-h')) return help();
30978
31909
  // 입력 오류는 자동 legacy migration·usage 기록보다 먼저 거부해야 실패 호출이 상태를 바꾸지 않는다.
30979
- if (!_rejectUnregisteredFlags()) return;
31910
+ if (!_rejectUnregisteredFlags(cmd)) return;
30980
31911
  if (!_rejectMalformedFlagForms(cmd, args)) return;
30981
31912
  if (!_validateCommandFlags(cmd, args)) return;
30982
31913
  // 값이 존재하지만 의미가 잘못된 --keep 도 legacy migration/write 전에 거부한다.
@@ -30986,7 +31917,7 @@ async function main() {
30986
31917
  if (_k !== null) {
30987
31918
  const _kn = Number(_k);
30988
31919
  if (!Number.isInteger(_kn) || _kn < 0) {
30989
- const _enK = _uiLang(process.cwd()) === 'en';
31920
+ const _enK = _errorUiLang(process.cwd(), cmd) === 'en';
30990
31921
  failJson(has('--json'), 'invalid_keep', _enK
30991
31922
  ? `--keep must be an integer >= 0 (got: ${_k})`
30992
31923
  : `--keep 은 0 이상의 정수여야 합니다 (받은 값: ${_k})`);
@@ -31071,7 +32002,7 @@ async function main() {
31071
32002
  // 플래그를 적었는데 값이 없으면 그건 오타다. 조용히 cwd 로 떨어뜨리지 않는다.
31072
32003
  const _pathTyped = process.argv.slice(2).some((t) => t === '--path' || (typeof t === 'string' && t.startsWith('--path=')));
31073
32004
  if (_pathTyped && (typeof _p !== 'string' || !_p.trim())) {
31074
- const _en = _uiLang(process.cwd()) === 'en';
32005
+ const _en = _errorUiLang(process.cwd(), cmd) === 'en';
31075
32006
  failJson(has('--json'), 'path_missing_value', _en
31076
32007
  ? '--path was given without a value — nothing was done'
31077
32008
  : '--path 에 값이 없습니다 — 아무것도 하지 않았습니다');
@@ -31080,7 +32011,7 @@ async function main() {
31080
32011
  if (_p && !_PATH_MAY_BE_ABSENT.has(cmd)) {
31081
32012
  const _abs = absRoot(_p);
31082
32013
  if (!exists(_abs)) {
31083
- const _en = _uiLang(process.cwd()) === 'en';
32014
+ const _en = _errorUiLang(process.cwd(), cmd) === 'en';
31084
32015
  failJson(has('--json'), 'path_not_found', _lineSafe(_en
31085
32016
  ? `path not found: ${_abs} — no directory was created`
31086
32017
  : `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`));
@@ -31091,7 +32022,7 @@ async function main() {
31091
32022
  // 프로젝트 root 는 디렉토리다. 파일이면 그렇다고 말한다(스캔처럼 파일이 정상 입력인 경로는 위 _strict 가 따로 다룬다).
31092
32023
  try {
31093
32024
  if (!fs.statSync(_abs).isDirectory()) {
31094
- const _en2 = _uiLang(process.cwd()) === 'en';
32025
+ const _en2 = _errorUiLang(process.cwd(), cmd) === 'en';
31095
32026
  failJson(has('--json'), 'path_not_a_directory', _lineSafe(_en2
31096
32027
  ? `--path is not a directory: ${_abs}`
31097
32028
  : `--path 가 디렉토리가 아닙니다: ${_abs}`));
@@ -31124,15 +32055,23 @@ async function main() {
31124
32055
  // init/migrate 는 이미 _warnIfStale 호출하므로 제외. mcp/version/help 등 출력 민감 명령도 제외.
31125
32056
  // 24h 캐시 (.leerness/cache/update-check.json) 활용 — 네트워크 비차단.
31126
32057
  const _staleSkip = new Set(['init', 'migrate', 'usage', 'mcp', 'release', 'session-close', '--version', '--help', 'help', 'update', 'whats-new']);
31127
- if (!_staleSkip.has(cmd) && process.env.LEERNESS_NO_STALE_CHECK !== '1' && !has('--no-stale-check')) {
32058
+ if (!_staleSkip.has(cmd) && !has('--json') && process.env.LEERNESS_NO_STALE_CHECK !== '1' && !has('--no-stale-check')) {
31128
32059
  try {
31129
- const root = absRoot(arg('--path', args[1] && !args[1].startsWith('-') ? args[1] : process.cwd()));
32060
+ const root = cmd === 'mode'
32061
+ ? _modeRoot(args)
32062
+ : absRoot(arg('--path', args[1] && !args[1].startsWith('-') ? args[1] : process.cwd()));
31130
32063
  // 캐시 fresh 시에만 즉시 비교 (네트워크 호출 X — 비차단). offline + cache 없으면 skip.
31131
32064
  const cached = readUpdateCache(root);
31132
32065
  if (cacheFresh(cached, 24) && cached.nextLeerness && compareVer(cached.nextLeerness, VERSION) > 0) {
31133
32066
  const isTty = process.stdout && process.stdout.isTTY;
31134
32067
  const C = isTty ? { y: s => `\x1b[33m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`, d: s => `\x1b[2m${s}\x1b[0m` } : { y: s => s, b: s => s, d: s => s };
31135
- process.stderr.write(C.y(' ⚠ ') + C.b(`leerness v${VERSION} → v${cached.nextLeerness} 사용 가능`) + C.d(` · ${C.b('npm i leerness@latest')} 권장 (LEERNESS_NO_STALE_CHECK=1 로 끄기)`) + '\n');
32068
+ const _staleEn = _uiLang(root) === 'en';
32069
+ process.stderr.write(C.y(' ⚠ ') + C.b(_staleEn
32070
+ ? `leerness v${VERSION} → v${cached.nextLeerness} available`
32071
+ : `leerness v${VERSION} → v${cached.nextLeerness} 사용 가능`)
32072
+ + C.d(_staleEn
32073
+ ? ` · ${C.b('npm i leerness@latest')} recommended (set LEERNESS_NO_STALE_CHECK=1 to disable)`
32074
+ : ` · ${C.b('npm i leerness@latest')} 권장 (LEERNESS_NO_STALE_CHECK=1 로 끄기)`) + '\n');
31136
32075
  }
31137
32076
  } catch {}
31138
32077
  }
@@ -31141,9 +32080,23 @@ async function main() {
31141
32080
  if (cmd === 'mode') {
31142
32081
  const _tty = process.stdout && process.stdout.isTTY;
31143
32082
  const _cy = s => _tty ? `\x1b[36m${s}\x1b[0m` : s, _dm = s => _tty ? `\x1b[2m${s}\x1b[0m` : s;
31144
- const _mRoot = absRoot(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
32083
+ const _mRoot = _modeRoot(args);
31145
32084
  const _mj = has('--json');
31146
- if (!exists(path.join(_mRoot, '.leerness'))) { failJson(_mj, 'harness_missing', `leerness 미설치: ${_mRoot} — 먼저 leerness init`); return; }
32085
+ const _mEn = _uiLang(_mRoot) === 'en';
32086
+ // Machine errors keep their established canonical text; language selection
32087
+ // is presentation-only, as with the other T-0092 priority surfaces.
32088
+ const _mt = (ko, en) => (_mEn && !_mj ? en : ko);
32089
+ const _mReason = chk => (_mEn
32090
+ ? ({ read_failed: 'read failed', json_parse_failed: 'JSON parse failed', not_an_object: 'top level is not an object' }[chk.code] || 'unknown manifest error')
32091
+ : chk.reason);
32092
+ const _mRejectCorrupt = chk => failJson(_mj, 'manifest_corrupt', _mt(
32093
+ `manifest.json 손상(${chk.reason}) — 덮어쓰기 거부: ${chk.path}\n 복구 후 재시도하세요(지금 쓰면 project/language 등 남은 필드가 사라집니다)`,
32094
+ `manifest.json is corrupt (${_mReason(chk)}) — refusing to overwrite: ${chk.path}\n Repair it and retry (writing now would remove remaining fields such as project/language)`,
32095
+ ));
32096
+ if (!exists(path.join(_mRoot, '.leerness'))) {
32097
+ failJson(_mj, 'harness_missing', _mt(`leerness 미설치: ${_mRoot} — 먼저 leerness init`, `leerness is not installed: ${_mRoot} — run leerness init first`));
32098
+ return;
32099
+ }
31147
32100
  const sub = args[1] && !args[1].startsWith('-') && !_MODES.includes(args[1]) && !/^[.\/\\]|^[A-Za-z]:/.test(args[1]) ? args[1] : (_MODES.includes(args[1]) ? 'set' : 'get');
31148
32101
  const cur = _projectMode(_mRoot);
31149
32102
  // 1.36.107: 손상을 조용히 'standard' 로 보고하지 않는다 — 사용자는 설정이 날아간 걸 알아야 한다.
@@ -31151,44 +32104,65 @@ async function main() {
31151
32104
  if (sub === 'get') {
31152
32105
  if (_mj) { log(JSON.stringify({ ok: !_mChk.corrupt, root: _mRoot, mode: cur, modes: _MODES, corrupt: !!_mChk.corrupt, corruptReason: _mChk.reason || null }, null, 2)); if (_mChk.corrupt) process.exitCode = 1; return; }
31153
32106
  log(_cy(`# leerness mode — ${cur}`));
31154
- if (_mChk.corrupt) log(` ⚠ manifest.json 손상(${_mChk.reason}) — 위 값은 **저장값이 아니라 기본값**입니다: ${_mChk.path}`);
31155
- log(` minimal 핵심 3종만 읽힌다 (handoff · verify-claim · session close)`);
31156
- log(` standard 현재 기본 — 전체 지침/상태 문서`);
32107
+ if (_mChk.corrupt) log(_mt(
32108
+ ` ⚠ manifest.json 손상(${_mChk.reason}) — 위 값은 **저장값이 아니라 기본값**입니다: ${_mChk.path}`,
32109
+ ` ⚠ manifest.json is corrupt (${_mReason(_mChk)}) — the value above is the default, not the stored value: ${_mChk.path}`,
32110
+ ));
32111
+ log(_mt(
32112
+ ` minimal 핵심 3종만 읽힌다 (handoff · verify-claim · session close)`,
32113
+ ` minimal loads only the core three (handoff · verify-claim · session close)`,
32114
+ ));
32115
+ log(_mt(
32116
+ ` standard 현재 기본 — 전체 지침/상태 문서`,
32117
+ ` standard current default — all instruction/state documents`,
32118
+ ));
31157
32119
  // 목록은 _MODES 에서 유도한다 — 하드코딩하면 등급을 뺐을 때 안내만 남아 없는 등급을 계속 광고한다
31158
32120
  // (실제로 strict 제거 후 이 줄만 살아남아 게이트가 잡았다).
31159
- log(_dm(` 변경: leerness mode set <${_MODES.join('|')}> · 적재량: leerness context budget`));
32121
+ log(_dm(_mt(
32122
+ ` 변경: leerness mode set <${_MODES.join('|')}> · 적재량: leerness context budget`,
32123
+ ` Change: leerness mode set <${_MODES.join('|')}> · Load: leerness context budget`,
32124
+ )));
31160
32125
  return;
31161
32126
  }
31162
32127
  if (sub === 'set') {
31163
32128
  const want = _normMode(args.find(a => _MODES.includes(a)));
31164
- if (!args.some(a => _MODES.includes(a))) { failJson(_mj, 'invalid_mode', `mode 는 ${_MODES.join('|')} 중 하나입니다`); return; }
32129
+ if (!args.some(a => _MODES.includes(a))) {
32130
+ failJson(_mj, 'invalid_mode', _mt(`mode 는 ${_MODES.join('|')} 중 하나입니다`, `mode must be one of ${_MODES.join('|')}`));
32131
+ return;
32132
+ }
31165
32133
  // 1.36.107 (손상 스토어 사냥): 종전엔 `catch { m = {} }` 후 덮어썼다 — 손상 매니페스트 위에 새로 쓰면서
31166
32134
  // project·language·installedAt 등 **남아 있던 필드까지 통째로 날렸다**(실측 3/3 손상 유형에서 재현).
31167
32135
  // 1.36.49 가 토글에 세운 규율과 같다: 변경 진입점은 fail-closed. 읽지 못하면 쓰지 않는다.
31168
32136
  if (_mChk.corrupt) {
31169
- failJson(_mj, 'manifest_corrupt', `manifest.json 손상(${_mChk.reason}) — 덮어쓰기 거부: ${_mChk.path}\n 복구 후 재시도하세요(지금 쓰면 project/language 등 남은 필드가 사라집니다)`);
32137
+ _mRejectCorrupt(_mChk);
31170
32138
  return;
31171
32139
  }
31172
32140
  const mf = _mChk.path;
31173
32141
  // 1.36.108 (T-0097): 락 안에서 **다시 읽는다**. 1.36.107 은 손상 판정과 필드 보존을 넣었지만 그 사이 창은 남아 있었다 —
31174
32142
  // codex 검수가 rename 직전에 writer 를 주입해 그 writer 의 project/language/installedAt 이 전부 사라지는 것을 재현했다.
31175
32143
  // 락 밖에서 읽은 _mChk.data 로 쓰면 "보존" 하는 것이 **낡은 스냅샷**이라 옆 프로세스의 변경을 지운다.
31176
- _withLock(mf, () => {
32144
+ const _modeWrite = _withLock(mf, () => {
31177
32145
  const fresh = _readManifestChecked(_mRoot);
31178
- if (fresh.corrupt) return; // 락 대기 중 손상됐다면 쓰지 않는다(fail-closed 유지)
32146
+ if (fresh.corrupt) return { ok: false, check: fresh }; // 호출부도 성공/재생성을 중단해야 fail-closed 다
31179
32147
  // 1.36.107 (codex 검수, 재현됨): Object.assign 은 [[Set]] 을 쓰므로 `__proto__` 키에서 setter 가 걸려
31180
32148
  // 그 필드를 **조용히 버린다**(실측: 주입 후 mode set → 파일에서 사라짐). 스프레드는 CreateDataProperty 라 보존한다.
31181
32149
  // 병적인 입력이긴 하나 이 수정이 내건 계약이 "남은 필드를 전부 보존" 이므로, 예외를 두면 계약이 거짓이 된다.
32150
+ // T-0164 재검수 P2: 이전 값을 쓰기 **전에** total 정규화한다. 쓰고 나서 hostile object 변환이 throw 하면
32151
+ // manifest 만 바뀌고 지침 재생성은 빠지는 부분 커밋이 된다.
32152
+ const previous = _normMode(fresh.data.mode);
31182
32153
  const m = { ...fresh.data };
31183
32154
  m.mode = want; writeUtf8(mf, JSON.stringify(m, null, 2) + '\n');
32155
+ return { ok: true, previous };
31184
32156
  });
32157
+ if (!_modeWrite.ok) { _mRejectCorrupt(_modeWrite.check); return; }
32158
+ const previous = _modeWrite.previous;
31185
32159
  // 지침 파일은 등급의 산출물이므로 즉시 재생성한다 — 안 그러면 mode 는 이름만 바뀌고 읽히는 양은 그대로다.
31186
32160
  let regenerated = 0;
31187
32161
  try {
31188
32162
  const _l = _uiLang(_mRoot);
31189
32163
  const gen = coreFiles(_mRoot, _l, [], { mode: want });
31190
32164
  let prevGen = null;
31191
- try { prevGen = coreFiles(_mRoot, _l, [], { mode: cur }); } catch { prevGen = null; } // 직전 등급 템플릿(차감용)
32165
+ try { prevGen = coreFiles(_mRoot, _l, [], { mode: previous }); } catch { prevGen = null; } // 직전 등급 템플릿(차감용)
31192
32166
  for (const k of ['AGENTS.md', 'CLAUDE.md']) {
31193
32167
  if (!gen[k]) continue;
31194
32168
  const p = path.join(_mRoot, k);
@@ -31205,12 +32179,15 @@ async function main() {
31205
32179
  regenerated++;
31206
32180
  }
31207
32181
  } catch {}
31208
- if (_mj) { log(JSON.stringify({ ok: true, root: _mRoot, mode: want, previous: cur, regenerated }, null, 2)); return; }
31209
- ok(`운영 등급: ${cur} → ${want} (지침 ${regenerated}개 재생성)`);
31210
- log(_dm(` 적재량 확인: leerness context budget`));
32182
+ if (_mj) { log(JSON.stringify({ ok: true, root: _mRoot, mode: want, previous, regenerated }, null, 2)); return; }
32183
+ ok(_mt(
32184
+ `운영 등급: ${previous} → ${want} (지침 ${regenerated}개 재생성)`,
32185
+ `Operating mode: ${previous} → ${want} (${regenerated} instruction file${regenerated === 1 ? '' : 's'} regenerated)`,
32186
+ ));
32187
+ log(_dm(_mt(` 적재량 확인: leerness context budget`, ` Check load: leerness context budget`)));
31211
32188
  return;
31212
32189
  }
31213
- failJson(_mj, 'unknown_subcommand', `알 수 없는 mode 하위명령: ${sub} (가능: get, set)`);
32190
+ failJson(_mj, 'unknown_subcommand', _mt(`알 수 없는 mode 하위명령: ${sub} (가능: get, set)`, `Unknown mode subcommand: ${sub} (valid: get, set)`));
31214
32191
  return;
31215
32192
  }
31216
32193
  if (cmd === 'context' && args[1] === 'budget') {
@@ -31526,7 +32503,7 @@ async function main() {
31526
32503
  return v;
31527
32504
  };
31528
32505
  return require('../lib/routing').routeCmd(arg('--path', process.cwd()), _rtToks.join(' '),
31529
- { has, arg, _withLock, _resolveRole, _providerAuth, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
32506
+ { has, arg, _withLock, _resolveRole, _resolveRoles, _providerAuth, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
31530
32507
  }
31531
32508
  if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
31532
32509
  // 1.9.157: Provider Registry — 사용자 정의 provider 동적 추가
@@ -31750,6 +32727,7 @@ async function main() {
31750
32727
  }
31751
32728
  // 1.36.51 (사용자 요청 UR-0061): 모호성 질문 + 미리보기 승인 워크플로
31752
32729
  if (cmd === 'clarify') return _clar.clarifyCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')).join(' '), { has });
32730
+ if (cmd === 'lease') return fileLeaseCmd(arg('--path', process.cwd()), args[1], args[2], args.slice(3));
31753
32731
  if (cmd === 'preview') {
31754
32732
  // 전역 2-pass 파서가 command별 arity까지 반영한 positional 단일출처다.
31755
32733
  // preview 전용 raw 재파싱은 --keep 을 다시 value로 오인해 뒤의 P-ID를 삼켰다(T-0144 교차검토).
@@ -32178,7 +33156,7 @@ module.exports = {
32178
33156
  // (실제 CLI 설치 여부에 의존하는 단언은 그 CLI 가 없는 머신에서 공허해진다).
32179
33157
  _checkAgent,
32180
33158
  // 1.9.270: agent roles — 모델별 역할 부여 (사용자 명시) — 단위 테스트
32181
- ROLE_CATALOG, _normalizeRole, _pickModel, _rolesFile, _loadRoles, _saveRoles, _resolveRole, _suggestRoles, rolesCmd, _dispatchCommand,
33159
+ ROLE_CATALOG, _normalizeRole, _pickModel, _rolesFile, _loadRoleState, _loadRoles, _loadRolesChecked, _saveRoles, _resolveRoleFromState, _resolveRoles, _resolveRole, _suggestRoles, rolesCmd, _dispatchCommand,
32182
33160
  // 1.9.272: 권한/보안 표면 공개 (GPT-5.5 리뷰) — 단위 테스트
32183
33161
  CAPABILITY_SURFACE, POWERFUL_COMMANDS, capabilitiesCmd,
32184
33162
  // 1.9.275: 릴리스 채널 (npm dist-tag, GPT-5.5 리뷰) — 단위 테스트