agentlas 0.6.0 → 0.9.1

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +220 -4
  3. package/bin/agentlas.cjs +8 -8
  4. package/engine/agentlas-capabilities.cjs +34 -3
  5. package/engine/agentlas-core-harness.cjs +205 -0
  6. package/engine/agentlas-desktop-loadout.cjs +527 -0
  7. package/engine/agentlas-doctor.cjs +1 -1
  8. package/engine/agentlas-experience-exchange.cjs +2151 -0
  9. package/engine/agentlas-experience-intake.cjs +444 -0
  10. package/engine/agentlas-experience-mcp.cjs +1709 -0
  11. package/engine/agentlas-i18n.cjs +10 -10
  12. package/engine/agentlas-input.cjs +5 -4
  13. package/engine/agentlas-mcp-env.cjs +219 -0
  14. package/engine/agentlas-mcp-wrapper.cjs +51 -0
  15. package/engine/agentlas-memory-governance.cjs +1029 -0
  16. package/engine/agentlas-native-host.cjs +129 -39
  17. package/engine/agentlas-parity.cjs +339 -154
  18. package/engine/agentlas-repl.cjs +327 -44
  19. package/engine/agentlas-workforce.cjs +2991 -0
  20. package/engine/agentlas-workload-routing.cjs +523 -0
  21. package/engine/agentlas.cjs +1886 -270
  22. package/engine/bootstrap-schema.sql +1 -1
  23. package/engine/experience-taxonomy-v1.json +49 -0
  24. package/package.json +8 -4
  25. package/scripts/gen-bootstrap-schema.sh +0 -23
  26. package/test/bootstrap-race.cjs +0 -47
  27. package/test/capture-runtime-guard.cjs +0 -122
  28. package/test/cloud-asset-restore.cjs +0 -423
  29. package/test/cloud-cas-client.cjs +0 -333
  30. package/test/cloud-owner-restore.cjs +0 -183
  31. package/test/cloud-runtime-paths.cjs +0 -40
  32. package/test/cloud-save-publish.cjs +0 -453
  33. package/test/credential-env-regression.cjs +0 -52
  34. package/test/login-loopback-security.cjs +0 -115
  35. package/test/mcp-config-isolation.cjs +0 -36
  36. package/test/permission-mapping.cjs +0 -180
  37. package/test/route-regression.cjs +0 -121
  38. package/test/run-api-regression.cjs +0 -322
  39. package/test/runtime-env-protection.cjs +0 -45
  40. package/test/semver-precedence.cjs +0 -39
  41. package/test/smoke.sh +0 -90
  42. package/test/sqlite-driver-probe.cjs +0 -22
  43. package/test/terminal-ui-regression.cjs +0 -472
  44. package/test/timeout-regression.cjs +0 -218
  45. package/test/tool-workspace-boundary.cjs +0 -165
  46. package/test/update-safety.cjs +0 -376
@@ -29,6 +29,13 @@ const fs = require("node:fs");
29
29
  const { spawn } = require("node:child_process");
30
30
  const crypto = require("node:crypto");
31
31
  const { compareSemVer, normalizeSemVer, parseSemVer } = require("./semver.cjs");
32
+ const terminalAssets = require("./agentlas-experience-mcp.cjs");
33
+ const terminalExperienceExchange = require("./agentlas-experience-exchange.cjs");
34
+ const desktopOntologyLoadout = require("./agentlas-desktop-loadout.cjs");
35
+ const workloadRouting = require("./agentlas-workload-routing.cjs");
36
+ const terminalExperienceIntake = require("./agentlas-experience-intake.cjs");
37
+ const terminalMemoryGovernance = require("./agentlas-memory-governance.cjs");
38
+ const { captureCoreJsonSync, resolveCoreRuntimeRoot } = require("./agentlas-core-harness.cjs");
32
39
 
33
40
  // ── 앱과 동일한 userData 경로 (electron app.getPath('userData')와 일치) ──
34
41
  function userDataDir() {
@@ -93,6 +100,19 @@ function openNodeSqliteDb(p) {
93
100
  run: (...args) => stmt.run(...args),
94
101
  };
95
102
  },
103
+ // better-sqlite3 API 패리티 — 폴백 경로에서도 db.exec/db.pragma가 있어야 한다.
104
+ // 누락 시 ensureMemoryContextColumn 등 ALTER TABLE(exec)이 TypeError로 조용히 죽어
105
+ // (try/catch 삼킴) context_json 컬럼 마이그레이션이 되지 않고 memory 조회가 깨진다.
106
+ exec: (sql) => db.exec(sql),
107
+ pragma: (source) => {
108
+ const rows = db.prepare(`PRAGMA ${source}`).all();
109
+ // better-sqlite3 pragma()의 단일값 반환 관례를 근사(단일 컬럼·단일 행 → 스칼라).
110
+ if (rows.length === 1) {
111
+ const keys = Object.keys(rows[0]);
112
+ if (keys.length === 1) return rows[0][keys[0]];
113
+ }
114
+ return rows;
115
+ },
96
116
  transaction(fn) {
97
117
  return (...args) => {
98
118
  db.exec("BEGIN");
@@ -241,7 +261,7 @@ function saveMultimodalSettingsCli(db, patch) {
241
261
  db.prepare("INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
242
262
  .run(MULTIMODAL_META_KEY, JSON.stringify(next));
243
263
  } catch (e) {
244
- fail("multimodal settings 저장 실패: " + e.message);
264
+ fail("Failed to save multimodal settings: " + e.message);
245
265
  }
246
266
  return next;
247
267
  }
@@ -280,7 +300,17 @@ const AGENT_BUILD_TERMS = [
280
300
  const BUILD_ENTITY_RE = /(에이전트|agent|팀|team|회사|company)/i;
281
301
  const BUILD_VERB_RE = /(만들|만든|생성|구축|구성해|꾸려|세팅|패키징|scaffold|build|create|\bmake\b|set\s?up|spin\s?up)/i;
282
302
  function isAgentBuildIntent(prompt) {
283
- const p = routeNormalize(prompt);
303
+ // 경로/파일 참조는 빌드 의도의 증거가 아니다 — "/Users/x/agent-tools/notes.md 요약본
304
+ // 만들어줘"의 디렉터리명이나 "agent-notes.md" 같은 파일명이 BUILD_ENTITY_RE를 때려
305
+ // 메타빌더(score 1000)로 직행하던 우회로 차단. 빌드 의도는 산문에서만 읽는다.
306
+ // ⚠️ 남은 슬래시는 통째로 지우지 않고 공백으로만 벌린다 — "에이전트/팀 만들어줘"의
307
+ // 슬래시-엔티티("에이전트/팀")를 삭제하면 BUILD_ENTITY_RE가 못 맞아 빌드 의도를 놓친다.
308
+ // 진짜 경로는 이미 routeStripPaths(마지막 세그먼트만)+확장자 제거가 처리했다.
309
+ const p = routeNormalize(
310
+ routeStripPaths(prompt)
311
+ .replace(/\S+\.[A-Za-z0-9]{1,6}(?=\s|$)/g, " ")
312
+ .replace(/[\\/]+/g, " "),
313
+ );
284
314
  if (!p.trim() || isTrivialRoutePrompt(p)) return false;
285
315
  if (AGENT_BUILD_TERMS.some((term) => p.includes(routeNormalize(term)))) return true;
286
316
  // 예: "단일 에이전트 하나만 만들어줘", "팀 좀 꾸려줘", "make me an agent"
@@ -302,7 +332,9 @@ function resolveMetaBuilder(db) {
302
332
  }
303
333
  // "ai"/"llm" 같은 초범용 토큰은 모든 에이전트 프롬프트에 나오므로 판별력이 0이다 —
304
334
  // 이런 단어 하나로 전문 에이전트가 선택되던 오라우팅(예: 일반 맥 질문 → Pitch Deck Architect)을 막는다.
305
- const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "please", "ai", "llm", "인공지능", "에이아이", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
335
+ // "local"/"imported"/"team" 임포터 보일러플레이트("Imported local team")와 slug 접두/접미에
336
+ // 편재해 판별력이 없다 — 'team' 한 단어가 아무 임포트 팀의 slug 부분문자열(+6 strong)을 때리던 구멍.
337
+ const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "team", "please", "ai", "llm", "local", "imported", "인공지능", "에이아이", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
306
338
  const ROUTE_HINTS = [
307
339
  {
308
340
  slug: "agentlas-app-builder",
@@ -360,24 +392,46 @@ const ROUTE_HINTS = [
360
392
  function routeNormalize(value) {
361
393
  return String(value || "").toLowerCase().replace(/[_/]+/g, "-");
362
394
  }
395
+ // 경로 디렉터리 성분은 라우팅 의도가 아니다 — 마지막 세그먼트(파일/폴더명)만 남긴다.
396
+ // 사고(2026-07-12): "/Users/example/Projects/…/Appbridge_Template.이 …" 프롬프트의 경로 토큰
397
+ // ("users","example","projects","users-example-projects-")이 임포트 에이전트 system_prompt 속
398
+ // 절대경로와 맞아떨어져 +2씩 쌓이고 라우팅 근거에까지 노출됐다. 프롬프트/헤이스택 양쪽에
399
+ // 대칭 적용해 경로↔경로 우연 일치를 차단한다. 파일/폴더명은 실제 의도라서 보존한다.
400
+ // 규칙: 공백/인용부호/괄호 뒤(또는 문자열 시작)에서 시작하고, "세그먼트+구분자"가 2회 이상
401
+ // 이어지는 절대·홈·드라이브·UNC 경로만 경로로 본다 — "and/or", "서울/부산", 날짜(2026/07/12),
402
+ // "https://…"(콜론 뒤 //는 시작 조건 불충족)는 건드리지 않는다. 세그먼트 안의 단일 공백은
403
+ // 뒤가 대문자로 시작할 때만 허용해 "Mobile Documents"/"Application Support"는 접되,
404
+ // "/tmp/out 기획/디자인 …" 같은 한글 프로즈를 경로로 삼켜버리지 않는다. 상대경로는
405
+ // 확장자 있는 파일 참조("docs/plan/roadmap.md")만 접는다 — 디렉터리명("plan")이 힌트/이름
406
+ // strong 채널을 때리는 것을 막으면서 "서울/부산/대구" 같은 나열은 보존한다.
407
+ const ROUTE_PATH_RE = /(^|[\s"'`(<\[{])((?:~|[A-Za-z]:)?[\\/]{1,2}(?:[^\s\\/]+(?: [A-Z][^\s\\/]*)?[\\/]+){2,}[^\s\\/]*|(?:[^\s\\/]+[\\/]+){2,}[^\s\\/]+\.[A-Za-z0-9]{1,6})/g;
408
+ function routeStripPaths(value) {
409
+ return String(value || "").replace(ROUTE_PATH_RE, (whole, pre, p) => {
410
+ const segs = p.split(/[\\/]+/).filter(Boolean);
411
+ return pre + (segs.length ? segs[segs.length - 1] : "");
412
+ });
413
+ }
363
414
  function routeTokenize(value) {
364
- const matches = routeNormalize(value).match(/[a-z0-9][a-z0-9-]{1,}|[가-힣]{2,}/g) || [];
415
+ // 매치가 영숫자로 끝나도록 강제해 "users-mason-documents-" 같은 후행 하이픈 토큰을 원천 차단.
416
+ const matches = routeNormalize(routeStripPaths(value)).match(/[a-z0-9][a-z0-9-]*[a-z0-9]|[가-힣]{2,}/g) || [];
365
417
  const expanded = matches.flatMap((term) => term.split("-").filter(Boolean).concat(term));
366
418
  return [...new Set(expanded.filter((term) => term.length >= 2 && !ROUTE_STOP_WORDS.has(term)))];
367
419
  }
368
420
  // 정체성 존(slug/이름/태그라인) — 여기 적중은 강한 라우팅 신호. system_prompt 본문 적중은 약한 신호.
421
+ // 임포터 보일러플레이트 태그라인("Imported local team/agent")의 세 단어는 전부 스톱워드라
422
+ // 프롬프트 토큰이 될 수 없다 — 별도 필터 불필요.
369
423
  function routeIdentityHaystack(agent) {
370
- return routeNormalize([agent.slug, agent.name, agent.name_en, agent.tagline, agent.tagline_en].join("\n"));
424
+ return routeNormalize(routeStripPaths([agent.slug, agent.name, agent.name_en, agent.tagline, agent.tagline_en].join("\n")));
371
425
  }
372
426
  function routeHaystack(agent) {
373
- return routeNormalize([
427
+ return routeNormalize(routeStripPaths([
374
428
  agent.slug,
375
429
  agent.name,
376
430
  agent.name_en,
377
431
  agent.tagline,
378
432
  agent.tagline_en,
379
433
  String(agent.system_prompt || "").slice(0, 3500),
380
- ].join("\n"));
434
+ ].join("\n")));
381
435
  }
382
436
  const APP_BUILDER_EXPLICIT_TERMS = [
383
437
  "apps generate", "app builder", "make an app", "build an app", "create an app",
@@ -420,7 +474,7 @@ function isTrivialRoutePrompt(promptText) {
420
474
  return words.length <= 3 && TRIVIAL_ROUTE_PROMPTS.has(stripped);
421
475
  }
422
476
  function isAppBuilderWorthyRoutePrompt(prompt) {
423
- const promptText = routeNormalize(prompt);
477
+ const promptText = routeNormalize(routeStripPaths(prompt));
424
478
  if (!promptText.trim() || isTrivialRoutePrompt(promptText)) return false;
425
479
  const explicit = routeMatchedTerms(promptText, APP_BUILDER_EXPLICIT_TERMS);
426
480
  if (explicit.length) return true;
@@ -442,8 +496,10 @@ function routeHint(promptText, agent, lang) {
442
496
  if (!terms.length) return { score: 0, terms: [], reason: "" };
443
497
  return { score: 12 + terms.length * 3, terms, reason: lang === "ko" ? hint.reasonKo : hint.reasonEn };
444
498
  }
445
- function scoreRouteAgent(prompt, promptTerms, agent, lang) {
446
- const promptText = routeNormalize(prompt);
499
+ function scoreRouteAgent(prompt, promptTerms, agent, lang, pre) {
500
+ // 대칭 스트리핑 필수: promptText 이름(+20)·힌트(+12↑) strong 채널의 입력이라, 여기서
501
+ // 경로를 안 벗기면 "/Users/x/project-plan/…"의 디렉터리명이 strong 게이트를 그대로 뚫는다.
502
+ const promptText = routeNormalize(routeStripPaths(prompt));
447
503
  if (agent.slug === "agentlas-app-builder" && !isAppBuilderWorthyRoutePrompt(promptText)) {
448
504
  return {
449
505
  agent,
@@ -452,24 +508,33 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
452
508
  ? "전용 App을 만들 만큼 반복·상태·편집·자동화가 뚜렷하지 않아 App Builder 라우트를 보류했습니다"
453
509
  : "the request does not clearly need a dedicated App with durable workflow, state, editing, or automation",
454
510
  terms: [],
511
+ strong: false,
455
512
  };
456
513
  }
457
- const identityHay = routeIdentityHaystack(agent);
458
- const haystack = routeHaystack(agent);
514
+ // 헤이스택은 설치/임포트 시에만 변하므로 autoRouteAgent가 미리 계산해 넘긴다(중복 계산 제거).
515
+ const identityHay = (pre && pre.identityHay) || routeIdentityHaystack(agent);
516
+ const haystack = (pre && pre.haystack) || routeHaystack(agent);
459
517
  let score = 0;
518
+ let strong = false; // 이름 언급/정체성 적중/큐레이션 힌트 — 데스크탑처럼 "이름/힌트급 증거"가 있어야 위임한다
460
519
  const terms = [];
520
+ const seenNames = new Set();
461
521
  for (const name of [agent.slug, agent.name, agent.name_en].filter(Boolean)) {
462
522
  const n = routeNormalize(name);
463
523
  // 4자 미만 일반 단어("team","agent" 등)가 프롬프트에 우연히 들어가 +20을 독식하지 않도록 가드.
464
- if (n && n.length >= 4 && promptText.includes(n)) {
524
+ // name === name_en 인 임포트 에이전트(appbridge 등)가 +20을 받지 않도록 정규화 기준 dedupe.
525
+ if (!n || n.length < 4 || seenNames.has(n)) continue;
526
+ seenNames.add(n);
527
+ if (promptText.includes(n)) {
465
528
  score += 20;
466
529
  terms.push(name);
530
+ strong = true;
467
531
  }
468
532
  }
469
533
  for (const term of promptTerms) {
470
534
  if (identityHay.includes(term)) {
471
535
  score += 6; // 이름/태그라인 적중 = 그 에이전트의 정체성 자체를 부른 것
472
536
  terms.push(term);
537
+ strong = true;
473
538
  } else if (haystack.includes(term)) {
474
539
  score += term.length >= 5 ? 3 : 2;
475
540
  terms.push(term);
@@ -477,6 +542,7 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
477
542
  }
478
543
  const hint = routeHint(promptText, agent, lang);
479
544
  score += hint.score;
545
+ if (hint.score) strong = true;
480
546
  terms.push(...hint.terms);
481
547
  const unique = [...new Set(terms)].slice(0, 6);
482
548
  const reason = hint.reason || (lang === "ko"
@@ -486,13 +552,14 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
486
552
  : unique.length
487
553
  ? `request terms ${unique.map((term) => `"${term}"`).join(", ")} best match this agent's role/triggers`
488
554
  : "no specialist matched clearly, so the default project coordinator is safest");
489
- return { agent, score, reason, terms: unique };
490
- }
491
- // 라우팅 확신 임계값. 이름/태그라인 적중(+6), 라우트 힌트(+12↑), 이름 포함(+20)만 전문 라우트로 인정하고,
492
- // system_prompt 본문의 약한 단어 적중(+2~3) 한두 개로는 에이전트를 절대 선택하지 않는다.
493
- // 임계값 미만이면 "직답"(에이전트·능력 라우팅 없음) 일반 질문이 Pitch Deck Architect 같은
494
- // 무관 페르소나 + gemini 이미지 런타임으로 끌려가던 사고의 근본 수리.
495
- const MIN_ROUTE_SCORE = 6;
555
+ return { agent, score, reason, terms: unique, strong };
556
+ }
557
+ // 라우팅 확신 임계값 데스크탑 auto-router의 MIN_SPECIALIST_SCORE(10) 동일 기준.
558
+ // 위임에는 점수뿐 아니라 strong 신호(이름 포함 +20 / 정체성 적중 +6 / 큐레이션 힌트 +12↑)가
559
+ // 반드시 있어야 한다. system_prompt 본문의 약한 단어 적중(+2~3)이 쌓여도, strong 신호가
560
+ // 없으면 절대 위임하지 않는다. 미달이면 "직답"(에이전트·능력 라우팅 없음) 일반 질문이
561
+ // Pitch Deck Architect 같은 무관 페르소나 + gemini 이미지 런타임으로 끌려가던 사고의 근본 수리.
562
+ const MIN_ROUTE_SCORE = 10;
496
563
  function directRouteChoice(lang) {
497
564
  const resolvedLang = lang || prefsLang();
498
565
  return {
@@ -500,6 +567,7 @@ function directRouteChoice(lang) {
500
567
  agent: null,
501
568
  score: 0,
502
569
  terms: [],
570
+ strong: false,
503
571
  reason: resolvedLang === "ko"
504
572
  ? "특정 전문 에이전트가 필요 없는 일반 요청입니다"
505
573
  : "this is a general request that needs no specialist agent",
@@ -521,6 +589,7 @@ function autoRouteAgent(db, prompt, lang) {
521
589
  return {
522
590
  agent: meta,
523
591
  score: 1000,
592
+ strong: true,
524
593
  reason:
525
594
  resolvedLang === "ko"
526
595
  ? "새 에이전트/팀/회사를 만드는 요청이라 메타에이전트(빌더)로 라우팅했습니다"
@@ -532,13 +601,19 @@ function autoRouteAgent(db, prompt, lang) {
532
601
  const agents = listRoutableAgents(db).filter((agent) => !NON_GENERIC_ROUTE_SLUGS.has(agent.slug));
533
602
  if (!agents.length) return directRouteChoice(resolvedLang);
534
603
  let terms = routeTokenize(prompt);
604
+ // 헤이스택은 한 번만 계산해 IDF와 스코어링 양쪽에서 재사용한다.
605
+ const hays = agents.map((agent) => ({ identityHay: routeIdentityHaystack(agent), haystack: routeHaystack(agent) }));
535
606
  // IDF 근사 — 설치 에이전트 절반 이상의 haystack에 나오는 단어("ai","도구" 등)는 판별력이 없어 제외.
536
607
  if (agents.length >= 3) {
537
- const hays = agents.map((agent) => routeHaystack(agent));
538
- terms = terms.filter((term) => hays.filter((hay) => hay.includes(term)).length * 2 <= agents.length);
539
- }
540
- const ranked = agents.map((agent) => scoreRouteAgent(prompt, terms, agent, resolvedLang)).sort((a, b) => b.score - a.score);
541
- if (ranked[0] && ranked[0].score >= MIN_ROUTE_SCORE) return ranked[0];
608
+ terms = terms.filter((term) => hays.filter((h) => h.haystack.includes(term)).length * 2 <= agents.length);
609
+ }
610
+ const ranked = agents
611
+ .map((agent, i) => scoreRouteAgent(prompt, terms, agent, resolvedLang, hays[i]))
612
+ .sort((a, b) => b.score - a.score);
613
+ // 1위가 아니라 "임계값+strong을 모두 만족하는 최고 순위"를 뽑는다 — 장황한 프롬프트의
614
+ // 약한 단어 적중이 점수 1위를 먹어도, 자격 있는 전문 에이전트가 직답으로 밀려나지 않는다.
615
+ const pick = ranked.find((r) => r.score >= MIN_ROUTE_SCORE && r.strong);
616
+ if (pick) return pick;
542
617
  return directRouteChoice(resolvedLang);
543
618
  }
544
619
  function autoRouteNote(choice, lang) {
@@ -597,6 +672,40 @@ function agentFolder(agent) {
597
672
  if (exists(path.join(cloudRoot, CLOUD_RESTORE_MARKER_PATH))) return cloudRoot;
598
673
  return path.join(userDataDir(), "agents", agent.slug);
599
674
  }
675
+ function exactAgentBaseForExecution(db, agent, runtimeExperience = null) {
676
+ if (!agent || agent.builtin) return null;
677
+ const portableId = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/;
678
+ let binding = null;
679
+ try {
680
+ if (tableExists(db, "installed_agent_hub_bindings")) {
681
+ binding = db.prepare(
682
+ "SELECT agent_definition_id,agent_release_id FROM installed_agent_hub_bindings WHERE installed_agent_id=?",
683
+ ).get(agent.id) || null;
684
+ }
685
+ } catch { binding = null; }
686
+ const route = routesMap()[agent.id] || {};
687
+ const markerResult = terminalExperienceExchange.readExactLocalBaseMarker(agentFolder(agent), agent.slug);
688
+ const marker = markerResult.marker;
689
+ const rawHash = String(marker?.packageHash || route.packageHash || route.definitionHash || "").replace(/^sha256:/i, "").toLowerCase();
690
+ const packageHash = /^[a-f0-9]{64}$/.test(rawHash) ? `sha256:${rawHash}` : null;
691
+ const explicitDefinition = String(runtimeExperience?.agentDefinitionId || "");
692
+ const explicitRelease = String(runtimeExperience?.baseAgentReleaseId || "");
693
+ if (portableId.test(explicitDefinition) && portableId.test(explicitRelease)) {
694
+ return { agentDefinitionId: explicitDefinition, agentReleaseId: explicitRelease, packageHash, authority: "explicit-runtime-binding" };
695
+ }
696
+ if (binding && portableId.test(String(binding.agent_definition_id)) && portableId.test(String(binding.agent_release_id))) {
697
+ return { agentDefinitionId: binding.agent_definition_id, agentReleaseId: binding.agent_release_id, packageHash, authority: "installed-hub-binding" };
698
+ }
699
+ if (!packageHash) return null;
700
+ const definitionDigest = sha(`terminal-local-definition\0${agent.id}\0${agent.slug}`);
701
+ const releaseDigest = sha(`terminal-local-release\0${definitionDigest}\0${packageHash}`);
702
+ return {
703
+ agentDefinitionId: `local-agent-definition:${definitionDigest.slice(0, 32)}`,
704
+ agentReleaseId: `local-agent-release:${releaseDigest.slice(0, 32)}`,
705
+ packageHash,
706
+ authority: "exact-local-package-hash",
707
+ };
708
+ }
600
709
  function agentSystemPromptCli(agent) {
601
710
  return agent && agent.system_prompt ? agent.system_prompt : `You are ${agent?.name || "an Agentlas agent"}.`;
602
711
  }
@@ -704,7 +813,7 @@ function buildImportSystemPrompt(dir, name, kind) {
704
813
  }
705
814
  function importLocalFolderCli(db, absPath) {
706
815
  const dir = path.resolve(absPath);
707
- if (!isDir(dir)) fail(`폴더가 아닙니다: ${absPath}`);
816
+ if (!isDir(dir)) fail(`Not a directory: ${absPath}`);
708
817
  const labels = detectRuntimeLabels(dir);
709
818
  const runtime = labels[0];
710
819
  const kind = detectKind(dir);
@@ -754,9 +863,14 @@ function importLocalFolderCli(db, absPath) {
754
863
  ).run(id, slug, name, name, tagline, tagline, systemPrompt, envReqsJson, now, tone);
755
864
  }
756
865
  }
866
+ // detectKind 결과를 DB에도 기록 — needsImage의 팀 body-veto 등 능력 판정이
867
+ // 데스크탑이 써준 entity_kind에 무임승차하지 않고 터미널 단독 임포트에서도 성립한다.
868
+ if (columnExists(db, "installed_agents", "entity_kind")) {
869
+ db.prepare("UPDATE installed_agents SET entity_kind=? WHERE id=?").run(kind, id);
870
+ }
757
871
  // 라우트 저장
758
872
  routes[id] = { agentId: id, path: dir, runtime, labels, kind, importedAt: now };
759
- fs.writeFileSync(path.join(userDataDir(), "agent-routes.json"), JSON.stringify(routes, null, 2), "utf8");
873
+ writeJsonPrivateAtomicCli(path.join(userDataDir(), "agent-routes.json"), routes);
760
874
 
761
875
  // 팀이면 회사(firm)로도 등록 → 앱 FIRMS 목록 + `agentlas firm <slug>` 사용 가능. slug 기준 멱등.
762
876
  let firm = null;
@@ -804,15 +918,15 @@ function upsertLocalTeamFirmCli(db, dir, ceoAgentId, agentSlug, name, tagline) {
804
918
  return { id, slug: firmSlug };
805
919
  }
806
920
  function cmdImport(db, absPath) {
807
- if (!absPath) fail("사용법: agentlas import <폴더경로>");
921
+ if (!absPath) fail("Usage: agentlas import <folder-path>");
808
922
  const r = importLocalFolderCli(db, absPath);
809
- out(`${r.updated ? "갱신" : "임포트"} 완료: ${r.name} (${r.kind})`);
923
+ out(`${r.updated ? "Updated" : "Imported"}: ${r.name} (${r.kind})`);
810
924
  out(` slug: ${r.slug}`);
811
925
  out(` runtime: ${r.runtime} [${r.labels.join(", ")}]`);
812
926
  out(` path: ${r.path}`);
813
- if (r.firmSlug) out(` firm: ${r.firmSlug} (FIRMS 등록됨 사이드바 + 'agentlas firm ${r.firmSlug}')`);
927
+ if (r.firmSlug) out(` firm: ${r.firmSlug} (registered in Firms Desktop sidebar + 'agentlas firm ${r.firmSlug}')`);
814
928
  out("");
815
- out(`실행: agentlas ${r.slug} "..." · agentlas run ${r.slug} "..." (대상 프로젝트 폴더에서 실행)`);
929
+ out(`Run: agentlas ${r.slug} "..." · agentlas run ${r.slug} "..." (run from the target project folder)`);
816
930
  }
817
931
 
818
932
  // ── Agentlas Cloud packaging / marketplace ────────────────────────────────
@@ -831,6 +945,7 @@ const CLOUD_AGENT_FILES = new Set(["AGENT.md", "AGENTS.md", "CLAUDE.md", "GEMINI
831
945
  const CLOUD_SKIP_DIRS = new Set([".git", ".next", ".studio-runtime", ".turbo", "build", "coverage", "dist", "node_modules", "out", "release"]);
832
946
  const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /^cloud-asset-state\.v1\.json$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
833
947
  const CLOUD_ROUTING_CARD_PATH = ".agentlas/routing-card.json";
948
+ const CLOUD_LOCAL_EXPERIENCE_LINEAGE_PATH = ".agentlas/experience-relations.jsonl";
834
949
  const CLOUD_ROUTING_CARD_CAPABILITY_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/;
835
950
  const CLOUD_ROUTING_CARD_STATUSES = new Set(["draft", "searchable", "candidate", "routing_ready", "trusted"]);
836
951
  const CLOUD_SECRET_RE = [
@@ -887,7 +1002,7 @@ function hubTimeoutError(kind, ms) {
887
1002
  /** Hub/Cloud fetch + body reader. Headers 전 connect, chunk 사이 idle, 전 구간 total timeout. */
888
1003
  async function fetchHubCli(url, init = {}, options = {}) {
889
1004
  const fetchImpl = options.fetch || globalThis.fetch;
890
- if (typeof fetchImpl !== "function") throw new Error(" 런타임에 fetch가 없습니다.");
1005
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this runtime.");
891
1006
  const timeout = options.timeoutConfig ? directHubTimeoutConfig(options.timeoutConfig) : hubTimeoutConfig(options.env || process.env);
892
1007
  const controller = new AbortController();
893
1008
  const upstreamSignal = init.signal;
@@ -951,7 +1066,7 @@ async function fetchHubCli(url, init = {}, options = {}) {
951
1066
  } else {
952
1067
  const raw = Buffer.from(await Promise.race([response.arrayBuffer(), terminal]));
953
1068
  bytes = raw.length;
954
- if (bytes > HUB_RESPONSE_MAX_BYTES) throw new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
1069
+ if (bytes > HUB_RESPONSE_MAX_BYTES) throw new Error(`Hub response exceeds the allowed size (${HUB_RESPONSE_MAX_BYTES} bytes).`);
955
1070
  chunks.push(raw);
956
1071
  }
957
1072
  if (idleTimer) clearTimeout(idleTimer);
@@ -976,7 +1091,7 @@ function parseHubJsonCli(response, label) {
976
1091
  try {
977
1092
  return JSON.parse(response.text || "null");
978
1093
  } catch {
979
- throw new Error(`${label} 응답 JSON 형식이 올바르지 않습니다.`);
1094
+ throw new Error(`${label} returned invalid JSON.`);
980
1095
  }
981
1096
  }
982
1097
 
@@ -1050,6 +1165,7 @@ async function cmdCloud(db, args, runtimeOverride) {
1050
1165
  " list [--json] list packages in your private Agent Cloud",
1051
1166
  " restore <slug> [--json] restore an owned Cloud package on this machine",
1052
1167
  " install <slug> compatibility alias: install from the public Hub",
1168
+ " plugin add <slug> install a Hub plugin (MCP servers)",
1053
1169
  " delete <slug> [--scope owner-private|hub-public] [--json]",
1054
1170
  " conditionally delete one exact observed Cloud revision",
1055
1171
  " search \"<what you need>\" [--limit 10]",
@@ -1068,7 +1184,7 @@ async function cmdCloud(db, args, runtimeOverride) {
1068
1184
  const result = await listOwnedCloudAgentsCli(Number(flags.limit || 100));
1069
1185
  if (flags.json) return out(JSON.stringify(result, null, 2));
1070
1186
  const agents = Array.isArray(result.results) ? result.results : [];
1071
- if (!agents.length) return out("Private Agent Cloud에 저장된 에이전트가 없습니다.");
1187
+ if (!agents.length) return out("No agents are stored in Private Agent Cloud.");
1072
1188
  for (const agent of agents) out(`${agent.slug}\t${agent.name || agent.nameEn || agent.slug}\t${agent.entityKind || "agent"}`);
1073
1189
  return;
1074
1190
  }
@@ -1164,8 +1280,8 @@ async function cmdCloud(db, args, runtimeOverride) {
1164
1280
  async function packageCloudAgentCli(db, root, opts) {
1165
1281
  const requestedRoot = path.resolve(root);
1166
1282
  let st;
1167
- try { st = fs.lstatSync(requestedRoot); } catch { throw new Error(`폴더를 찾을 없습니다: ${root}`); }
1168
- if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`실제 폴더가 아닙니다: ${root}`);
1283
+ try { st = fs.lstatSync(requestedRoot); } catch { throw new Error(`Folder not found: ${root}`); }
1284
+ if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`Not a real directory: ${root}`);
1169
1285
  const rootPath = fs.realpathSync.native(requestedRoot);
1170
1286
  const visibility = opts.visibility || "private-link";
1171
1287
  const isPublicHubPublish = visibility === "marketplace";
@@ -1620,6 +1736,12 @@ function scanCloudFolderCli(rootPath) {
1620
1736
  if (entry.name.startsWith("._")) continue;
1621
1737
  const abs = path.join(dir, entry.name);
1622
1738
  const rel = path.relative(rootPath, abs).split(path.sep).join("/");
1739
+ if (cloudIsLocalExperienceLineagePath(rel)) {
1740
+ let bytes = 0;
1741
+ try { bytes = Number(fs.lstatSync(abs).size) || 0; } catch { /* excluded local state */ }
1742
+ files.push({ path: rel, bytes, sha256: "", kind: "text", included: false, reason: "experience-lineage-separate-asset" });
1743
+ continue;
1744
+ }
1623
1745
  if (cloudPortablePathKey(rel) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
1624
1746
  // Local restore/CAS metadata is runtime state, never portable asset
1625
1747
  // data, but it must be captured with the same no-follow stability gate.
@@ -1858,19 +1980,19 @@ function cloudCasResponseErrorCli(response, label) {
1858
1980
  let body = null;
1859
1981
  try { body = JSON.parse(response.text || "null"); } catch { /* generic below */ }
1860
1982
  const code = body && typeof body.code === "string" ? body.code : "cloud_request_failed";
1861
- let message = `${label} 실패 ${response.status}`;
1983
+ let message = `${label} failed with HTTP ${response.status}`;
1862
1984
  if (response.status === 412 && code === "cloud_agent_revision_conflict") {
1863
1985
  const current = body && body.current ? body.current : body && body.conflict && body.conflict.current;
1864
1986
  message = current
1865
1987
  ? `다른 PC에서 이 Agent Cloud 자산이 변경되었습니다. 자동 덮어쓰기는 중단했습니다. \`agentlas cloud list\`로 최신 revision을 확인하고 \`agentlas cloud restore ${current.slug || "<slug>"}\`로 복원한 뒤 변경 사항을 병합하세요.`
1866
1988
  : "이 Agent Cloud 자산은 다른 PC에서 삭제되었거나 다른 식별자로 다시 생성되었습니다. 자동 재생성은 중단했습니다. `agentlas cloud list`로 현재 상태를 확인하세요.";
1867
1989
  } else if (response.status === 428 && code === "client_upgrade_required") {
1868
- message = "기존 Cloud 자산을 안전하게 갱신할 base revision이 없습니다. 서버 revision 자동 복사하지 않습니다. `agentlas cloud list`로 확인하고 `agentlas cloud restore <slug>`로 복원한 다시 저장하세요.";
1990
+ message = "No base revision is available to safely update the existing Cloud asset. The server revision will not be copied automatically. Check `agentlas cloud list`, restore with `agentlas cloud restore <slug>`, then save again.";
1869
1991
  } else if (response.status === 503 && code === "cloud_mutations_maintenance") {
1870
1992
  const retryAfter = response.headers && typeof response.headers.get === "function" ? response.headers.get("retry-after") : null;
1871
- message = `Agent Cloud 저장/삭제가 잠시 점검 중입니다${retryAfter ? ` ( ${retryAfter} 후 재시도)` : ""}. 읽기·목록·복원은 계속 사용할 있습니다.`;
1993
+ message = `Agent Cloud save/delete is temporarily under maintenance${retryAfter ? ` (retry in about ${retryAfter} seconds)` : ""}. Read, list, and restore remain available.`;
1872
1994
  } else if (body && typeof body.error === "string") {
1873
- message = `${label} 실패 ${response.status}: ${body.error.slice(0, 300)}`;
1995
+ message = `${label} failed with HTTP ${response.status}: ${body.error.slice(0, 300)}`;
1874
1996
  }
1875
1997
  const error = new Error(message);
1876
1998
  error.code = code;
@@ -1882,8 +2004,8 @@ function cloudCasResponseErrorCli(response, label) {
1882
2004
 
1883
2005
  async function registerCloudAgentCli(manifest, bundlePath, review, visibility, options = {}) {
1884
2006
  const cookie = await cloudSessionCookieCli();
1885
- if (!cookie) fail("agentlas.cloud 로그인이 필요합니다. 데스크톱 앱에서 로그인하거나 AGENTLAS_SESSION을 설정하세요.");
1886
- if (typeof fetch !== "function") fail(" 런타임에 fetch가 없습니다( 런타임으로 실행 필요).");
2007
+ if (!cookie) fail("Agent Cloud sign-in is required. Sign in through Desktop or set AGENTLAS_SESSION.");
2008
+ if (typeof fetch !== "function") fail("fetch is unavailable in this runtime (run through the app runtime).");
1887
2009
  const base = (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
1888
2010
  const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
1889
2011
  const expectedScope = cloudScopeForVisibility(visibility);
@@ -1956,8 +2078,8 @@ async function deleteCloudAgentCli(slug, options = {}) {
1956
2078
  const safeSlug = String(slug || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1957
2079
  if (!safeSlug) fail("usage: agentlas cloud delete <slug> [--json]");
1958
2080
  const cookie = await cloudSessionCookieCli();
1959
- if (!cookie) fail("agentlas.cloud 로그인이 필요합니다. 데스크톱 앱에서 로그인하거나 AGENTLAS_SESSION을 설정하세요.");
1960
- if (typeof fetch !== "function") fail(" 런타임에 fetch가 없습니다( 런타임으로 실행 필요).");
2081
+ if (!cookie) fail("Agent Cloud sign-in is required. Sign in through Desktop or set AGENTLAS_SESSION.");
2082
+ if (typeof fetch !== "function") fail("fetch is unavailable in this runtime (run through the app runtime).");
1961
2083
  const scope = options.scope == null ? null : normalizeCloudScopeFlagCli(options.scope);
1962
2084
  if (options.scope != null && !scope) throw new Error("--scope must be owner-private or hub-public");
1963
2085
  const localEntry = findCloudAssetDescriptorCli(safeSlug, scope);
@@ -2060,28 +2182,121 @@ async function cloudSessionCookieCli() {
2060
2182
  async function cmdCloudInstall(db, slug) {
2061
2183
  if (!slug) fail("usage: agentlas cloud install <slug>");
2062
2184
  const listing = await fetchCloudManifestCli(slug);
2063
- if (!listing) fail(`Hub agent 찾을 없습니다: ${slug}`);
2185
+ if (!listing) fail(`Hub agent not found: ${slug}`);
2064
2186
  if (listing.delivery && listing.delivery.mode === "call_only") {
2065
- fail(`이 Hub 에이전트는 소스 설치가 허용되지 않은 call-only 자산입니다. 실행: agentlas call ${slug}`);
2187
+ fail(`This Hub agent is call-only and cannot be installed from source. Run: agentlas call ${slug}`);
2066
2188
  }
2067
2189
  const agent = persistCloudListingCli(db, listing);
2068
2190
  out(`✓ Hub installed ${agent.slug} — ${agent.name}`);
2069
2191
  if (agent.localPath) out(` files: ${agent.localPath}`);
2070
2192
  }
2071
2193
 
2194
+ // ── Hub 플러그인 설치 ────────────────────────────────────────────────────────
2195
+ // 서버는 처음부터 준비돼 있었다: /api/plugins/<slug>가 agentlas.plugin/v1 매니페스트를 주고,
2196
+ // 그 라우트 주석이 이 CLI(`agentlas plugin add <slug>`)를 소비자로 지목한다. 그런데 이 명령이
2197
+ // 구현된 적이 없어서, 카탈로그 146개가 전부 "존재하지 않는 설치 명령"을 광고하고 있었다.
2198
+ // (`agentlas install`은 marketplace.get_manifest{kind:"agent"} 고정이라 플러그인엔 안 먹는다.)
2199
+ async function fetchPluginManifestCli(slug) {
2200
+ const base = (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
2201
+ const resp = await fetchHubCli(`${base}/api/plugins/${encodeURIComponent(slug)}`, {
2202
+ headers: { accept: "application/json" },
2203
+ });
2204
+ if (resp.status === 404) return null;
2205
+ if (!resp.ok) fail(`plugin lookup failed with HTTP ${resp.status}`);
2206
+ const manifest = parseHubJsonCli(resp, "plugin manifest");
2207
+ if (!manifest || manifest.schema !== "agentlas.plugin/v1") {
2208
+ fail(`Unexpected plugin manifest schema for ${slug}.`);
2209
+ }
2210
+ return manifest;
2211
+ }
2212
+
2213
+ /** 매니페스트의 mcp[] 항목을 mcp_servers 행으로 정규화. stdio(command)와 remote(url)를 구분한다. */
2214
+ function pluginMcpRowCli(slug, entry, index) {
2215
+ const source = typeof entry?.source === "string" ? entry.source.trim() : "";
2216
+ const name = (typeof entry?.name === "string" && entry.name.trim()) || `${slug}-${index + 1}`;
2217
+ const remote = /^https?:\/\//i.test(source);
2218
+ // 원격은 URL, stdio는 실행 커맨드다. 둘을 섞으면 codex config.toml 스키마 위반으로
2219
+ // 런타임이 통째로 죽는다(Runtime Doctor가 반복해서 잡던 사고 계열).
2220
+ if (!remote && !source) return null;
2221
+ const argv = remote ? [] : source.split(/\s+/).filter(Boolean);
2222
+ return {
2223
+ id: require("node:crypto").randomUUID(),
2224
+ catalogId: `hub:${slug}:${name}`,
2225
+ name,
2226
+ transport: remote ? "http" : "stdio",
2227
+ command: remote ? null : (argv[0] ?? null),
2228
+ argsJson: JSON.stringify(remote ? [] : argv.slice(1)),
2229
+ url: remote ? source : null,
2230
+ envKeysJson: JSON.stringify(
2231
+ Array.isArray(entry?.envKeys) ? entry.envKeys.filter((key) => typeof key === "string") : [],
2232
+ ),
2233
+ };
2234
+ }
2235
+
2236
+ async function cmdPluginAdd(db, slug) {
2237
+ if (!slug) fail('usage: agentlas plugin add <slug> (run agentlas plugin list first)');
2238
+ const manifest = await fetchPluginManifestCli(slug);
2239
+ if (!manifest) fail(`Hub plugin not found: ${slug}`);
2240
+ const entries = Array.isArray(manifest.mcp) ? manifest.mcp : [];
2241
+ const rows = entries.map((entry, index) => pluginMcpRowCli(slug, entry, index)).filter(Boolean);
2242
+ if (!rows.length) {
2243
+ // 설치할 MCP 서버가 없으면 조용히 성공했다고 하지 않는다 — 사용자는 이 플러그인이
2244
+ // 붙었다고 믿고 도구를 기대하게 된다.
2245
+ fail(
2246
+ `${slug} ships no MCP server to install (skills-only or source-link plugin). ` +
2247
+ `Nothing was registered. See: ${manifest.source?.repo || manifest.source?.homepage || "the plugin page"}`,
2248
+ );
2249
+ }
2250
+ let installed = 0;
2251
+ let reused = 0;
2252
+ for (const row of rows) {
2253
+ const existing = db.prepare("SELECT id FROM mcp_servers WHERE catalog_id = ? LIMIT 1").get(row.catalogId);
2254
+ if (existing) { reused += 1; continue; } // 멱등: 재설치가 중복 행을 만들지 않는다
2255
+ db.prepare(
2256
+ `INSERT INTO mcp_servers (id, catalog_id, name, name_en, transport, command, args_json, url, env_keys_json, enabled, installed_at)
2257
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
2258
+ ).run(
2259
+ row.id, row.catalogId, row.name, row.name, row.transport,
2260
+ row.command, row.argsJson, row.url, row.envKeysJson, new Date().toISOString(),
2261
+ );
2262
+ installed += 1;
2263
+ }
2264
+ out(`✓ Plugin installed ${manifest.slug} — ${manifest.name}`);
2265
+ out(` MCP servers: ${installed} added${reused ? `, ${reused} already present` : ""}`);
2266
+ const authKind = manifest.auth?.kind;
2267
+ if (authKind && authKind !== "none") {
2268
+ out(` ⚠ Requires ${authKind} — set credentials before use (agentlas creds).`);
2269
+ }
2270
+ if (Array.isArray(manifest.skills) && manifest.skills.length) {
2271
+ out(` skills declared: ${manifest.skills.map((skill) => skill.name).filter(Boolean).join(", ")}`);
2272
+ }
2273
+ out(" Only full-access turns wire active stdio servers into the runtime (agentlas mcp).");
2274
+ }
2275
+
2276
+ async function cmdPluginList() {
2277
+ const result = await callAgentlasMcpToolCli("marketplace.list_plugins", {});
2278
+ const plugins = (result && (result.plugins || result.results)) || [];
2279
+ if (!plugins.length) return out("No Hub plugins are available.");
2280
+ for (const plugin of plugins.slice(0, 60)) {
2281
+ out(`${String(plugin.slug || "").padEnd(32).slice(0, 32)} ${String(plugin.name || "").slice(0, 44)}`);
2282
+ }
2283
+ out("");
2284
+ out("Install: agentlas plugin add <slug>");
2285
+ }
2286
+
2072
2287
  async function callAgentlasMcpToolCli(name, args, { requireSession = false } = {}) {
2073
- if (typeof fetch !== "function") fail(" 런타임에 fetch가 없습니다( 런타임으로 실행 필요).");
2288
+ if (typeof fetch !== "function") fail("fetch is unavailable in this runtime (run through the app runtime).");
2074
2289
  const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
2075
2290
  const headers = { "content-type": "application/json" };
2076
2291
  const cookie = await cloudSessionCookieCli();
2077
- if (requireSession && !cookie) fail("Agent Cloud에는 로그인이 필요합니다. 먼저 `agentlas login`을 실행하세요.");
2292
+ if (requireSession && !cookie) fail("Agent Cloud sign-in is required. Run `agentlas login` first.");
2078
2293
  if (cookie) headers.cookie = cookie;
2079
2294
  const resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
2080
2295
  method: "POST",
2081
2296
  headers,
2082
2297
  body: JSON.stringify({ method: name, params: { name, arguments: args || {} } }),
2083
2298
  });
2084
- if (!resp.ok) fail(`${name} 실패 ${resp.status}`);
2299
+ if (!resp.ok) fail(`${name} failed with HTTP ${resp.status}`);
2085
2300
  const json = parseHubJsonCli(resp, name);
2086
2301
  if (json.error) fail(`${name}: ${json.error.message || "unknown error"}`);
2087
2302
  return json.result || null;
@@ -2364,6 +2579,17 @@ function persistCloudListingCli(db, listing) {
2364
2579
  throw error;
2365
2580
  }
2366
2581
  const localPath = restore?.path || null;
2582
+ // entity_kind 기록 — needsImage의 팀 body-veto가 로컬 폴더 임포트(detectKind)뿐 아니라
2583
+ // 클라우드/Hub 소스 설치 팀에도 걸리게 한다. 안 하면 팀 CEO 두뇌의 부서 키워드
2584
+ // ("Design HQ" 등)로 needsImage가 참이 되어 세션 런타임이 통째로 gemini로 하이재킹된다.
2585
+ // Hub가 준 entityKind를 우선하고, 없으면 materialize된 팩 폴더 구조로 판정한다.
2586
+ if (columnExists(db, "installed_agents", "entity_kind")) {
2587
+ let kind = String(listing.entityKind || "").toLowerCase();
2588
+ if (kind !== "team" && kind !== "agent") {
2589
+ kind = localPath && fs.existsSync(localPath) ? detectKind(localPath) : "agent";
2590
+ }
2591
+ db.prepare("UPDATE installed_agents SET entity_kind=? WHERE id=?").run(kind, id);
2592
+ }
2367
2593
  return existing
2368
2594
  ? { ...existing, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) }
2369
2595
  : { id, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) };
@@ -2709,6 +2935,13 @@ function printCloudPackageResult(result) {
2709
2935
  function cloudPackageSnapshot(files) {
2710
2936
  return new Map(files.map((file) => [file.path, file]));
2711
2937
  }
2938
+ function cloudIsLocalExperienceLineagePath(value) {
2939
+ const normalized = cloudPortablePathKey(String(value || "").replace(/\\/g, "/"));
2940
+ const canonical = cloudPortablePathKey(CLOUD_LOCAL_EXPERIENCE_LINEAGE_PATH);
2941
+ return normalized === canonical
2942
+ || normalized.startsWith(`${canonical}.`)
2943
+ || normalized.startsWith(cloudPortablePathKey(".agentlas/.experience-relations.jsonl."));
2944
+ }
2712
2945
  function cloudReadPublicCareerCard(snapshot, findings) {
2713
2946
  const relativePath = ".agentlas/public-career-card.json";
2714
2947
  const file = snapshot.get(relativePath);
@@ -2901,7 +3134,7 @@ function cloudHashPackage(files, version = CLOUD_PACKAGE_HASH_V1) {
2901
3134
  // 서버 package-contract.ts와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
2902
3135
  // 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
2903
3136
  // 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
2904
- for (const file of [...files].sort(cloudCodePointPathOrder)) {
3137
+ for (const file of [...files].filter((file) => !cloudIsLocalExperienceLineagePath(file.path)).sort(cloudCodePointPathOrder)) {
2905
3138
  h.update(file.path);
2906
3139
  h.update("\0");
2907
3140
  h.update(file.sha256);
@@ -6986,6 +7219,21 @@ function readJsonSafeCli(filePath, fallback) {
6986
7219
  function writeJsonSafeCli(filePath, value) {
6987
7220
  fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
6988
7221
  }
7222
+ // 원자적(temp+rename) + 소유자 전용(0600) JSON 쓰기. 세션 ID/경로 등 민감 상태 파일용:
7223
+ // (1) 크래시 중간 쓰기로 JSON이 깨져 routesMap()이 {}를 돌려주며 임포트 매핑을 통째로 잃던 사고,
7224
+ // (2) 기본 umask(0644)로 cli-sessions.json/agent-routes.json이 world-readable이던 정보 노출을 함께 막는다.
7225
+ function writeJsonPrivateAtomicCli(filePath, value) {
7226
+ const dir = path.dirname(filePath);
7227
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
7228
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
7229
+ try {
7230
+ fs.renameSync(tmp, filePath);
7231
+ } catch (e) {
7232
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
7233
+ throw e;
7234
+ }
7235
+ try { fs.chmodSync(filePath, 0o600); } catch { /* 일부 FS는 chmod 미지원 — best-effort */ }
7236
+ }
6989
7237
 
6990
7238
  function ontologySourceManifestSkeletonCli(root) {
6991
7239
  return {
@@ -7528,38 +7776,320 @@ function contextLine(json) {
7528
7776
  return "";
7529
7777
  }
7530
7778
  }
7531
- // 작업 폴더 반복 방문 → 활성화(.agentlas 생성). 앱의 activation.ts와 동일한 정책(2회).
7532
- function recordCliFolderVisit(db, projectPath) {
7533
- if (!tableExists(db, "folder_activity")) return { activated: false };
7534
- const now = new Date().toISOString();
7779
+ const AGENTLAS_PROJECT_STATE_IGNORE_START = "# >>> agentlas local project state >>>";
7780
+ const AGENTLAS_PROJECT_STATE_IGNORE_END = "# <<< agentlas local project state <<<";
7781
+ const AGENTLAS_GITIGNORE_MAX_BYTES = 1024 * 1024;
7782
+ const projectBootstrapStates = new Map();
7783
+
7784
+ function terminalProjectCandidateCli(projectPath) {
7535
7785
  try {
7536
- const row = db.prepare("SELECT visits, activated_at FROM folder_activity WHERE path=?").get(projectPath);
7537
- let visits, activatedAt;
7538
- if (row) {
7539
- visits = row.visits + 1; activatedAt = row.activated_at;
7540
- db.prepare("UPDATE folder_activity SET visits=?, last_seen=? WHERE path=?").run(visits, now, projectPath);
7541
- } else {
7542
- visits = 1; activatedAt = null;
7543
- db.prepare("INSERT INTO folder_activity (path, visits, activated_at, first_seen, last_seen) VALUES (?,?,NULL,?,?)").run(projectPath, visits, now, now);
7786
+ const root = path.resolve(projectPath || process.cwd());
7787
+ const unsafe = new Set([
7788
+ path.resolve(os.homedir()),
7789
+ path.parse(root).root,
7790
+ path.resolve(userDataDir()),
7791
+ path.resolve(runCwd()),
7792
+ ]);
7793
+ if (unsafe.has(root)) return null;
7794
+ const stat = fs.statSync(root);
7795
+ if (!stat.isDirectory()) return null;
7796
+ return root;
7797
+ } catch {
7798
+ return null;
7799
+ }
7800
+ }
7801
+
7802
+ function assertNoSymlinkInAgentlasStateCli(stateDir) {
7803
+ const pending = [stateDir];
7804
+ let visited = 0;
7805
+ while (pending.length && visited < 4096) {
7806
+ const current = pending.pop();
7807
+ visited += 1;
7808
+ let stat;
7809
+ try { stat = fs.lstatSync(current); } catch (error) {
7810
+ if (error && error.code === "ENOENT") continue;
7811
+ throw error;
7544
7812
  }
7545
- if (!activatedAt && visits >= 2) {
7546
- db.prepare("UPDATE folder_activity SET activated_at=? WHERE path=?").run(now, projectPath);
7547
- ensureProjectMemoryCli(projectPath);
7548
- activatedAt = now;
7813
+ if (stat.isSymbolicLink()) throw new Error(".agentlas local state must not contain symbolic links");
7814
+ if (stat.isDirectory()) {
7815
+ for (const entry of fs.readdirSync(current)) pending.push(path.join(current, entry));
7549
7816
  }
7550
- return { activated: !!activatedAt };
7551
- } catch { return { activated: false }; }
7817
+ }
7818
+ if (pending.length) throw new Error(".agentlas local state exceeds the safe bootstrap inspection limit");
7552
7819
  }
7553
- // `agentlas run` 등이 호출된 작업 디렉터리 → 활성 프로젝트 경로(또는 null).
7554
- function activeProjectPath(db) {
7820
+
7821
+ function readRegularUtf8FileNoFollowCli(filePath, maxBytes = AGENTLAS_GITIGNORE_MAX_BYTES) {
7822
+ let before;
7823
+ try { before = fs.lstatSync(filePath); } catch (error) {
7824
+ if (error && error.code === "ENOENT") return { exists: false, content: "", mode: 0o644, stat: null };
7825
+ throw error;
7826
+ }
7827
+ if (before.isSymbolicLink() || !before.isFile()) throw new Error(".gitignore must be a regular non-symbolic-link file");
7828
+ if (before.size > maxBytes) throw new Error(`.gitignore exceeds the ${maxBytes}-byte safe bootstrap limit`);
7829
+
7830
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
7831
+ let fd;
7555
7832
  try {
7556
- const cwd = process.cwd();
7557
- if (cwd === os.homedir() || cwd === userDataDir() || cwd === runCwd()) return null;
7558
- const v = recordCliFolderVisit(db, cwd);
7559
- return v.activated ? cwd : null;
7560
- } catch { return null; }
7833
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
7834
+ } catch (error) {
7835
+ if (process.platform !== "win32" || !noFollow || !["EINVAL", "ENOTSUP"].includes(error && error.code)) throw error;
7836
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY);
7837
+ }
7838
+ try {
7839
+ const opened = fs.fstatSync(fd);
7840
+ if (!opened.isFile()) throw new Error(".gitignore changed type during bootstrap");
7841
+ if (opened.size > maxBytes) throw new Error(`.gitignore exceeds the ${maxBytes}-byte safe bootstrap limit`);
7842
+ if (
7843
+ Number.isFinite(before.dev) && Number.isFinite(before.ino) &&
7844
+ (before.dev !== opened.dev || before.ino !== opened.ino)
7845
+ ) {
7846
+ throw new Error(".gitignore changed during bootstrap");
7847
+ }
7848
+ const chunks = [];
7849
+ let total = 0;
7850
+ while (total <= maxBytes) {
7851
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
7852
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
7853
+ if (!count) break;
7854
+ chunks.push(buffer.subarray(0, count));
7855
+ total += count;
7856
+ }
7857
+ if (total > maxBytes) throw new Error(`.gitignore exceeds the ${maxBytes}-byte safe bootstrap limit`);
7858
+ const after = fs.fstatSync(fd);
7859
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error(".gitignore changed while it was being read");
7860
+ let content;
7861
+ try { content = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total)); } catch {
7862
+ throw new Error(".gitignore must contain valid UTF-8 text");
7863
+ }
7864
+ return { exists: true, content, mode: before.mode & 0o777, stat: before };
7865
+ } finally {
7866
+ fs.closeSync(fd);
7867
+ }
7868
+ }
7869
+
7870
+ function assertFileSnapshotUnchangedCli(filePath, snapshot) {
7871
+ if (!snapshot.exists) {
7872
+ try {
7873
+ fs.lstatSync(filePath);
7874
+ throw new Error(".gitignore appeared during bootstrap");
7875
+ } catch (error) {
7876
+ if (error && error.code === "ENOENT") return;
7877
+ throw error;
7878
+ }
7879
+ }
7880
+ const current = fs.lstatSync(filePath);
7881
+ if (current.isSymbolicLink() || !current.isFile()) throw new Error(".gitignore changed type during bootstrap");
7882
+ const original = snapshot.stat;
7883
+ if (
7884
+ !original || current.dev !== original.dev || current.ino !== original.ino ||
7885
+ current.size !== original.size || current.mtimeMs !== original.mtimeMs
7886
+ ) {
7887
+ throw new Error(".gitignore changed during bootstrap");
7888
+ }
7889
+ }
7890
+
7891
+ function replaceRegularFileCli(tempPath, destinationPath, snapshot) {
7892
+ try {
7893
+ fs.renameSync(tempPath, destinationPath);
7894
+ return;
7895
+ } catch (error) {
7896
+ if (process.platform !== "win32" || !snapshot.exists || !["EEXIST", "EPERM", "EACCES"].includes(error && error.code)) {
7897
+ throw error;
7898
+ }
7899
+ }
7900
+
7901
+ // Windows can reject replacement of an existing file. Keep a same-directory
7902
+ // rollback copy so an interrupted replacement never silently loses user rules.
7903
+ assertFileSnapshotUnchangedCli(destinationPath, snapshot);
7904
+ const backup = `${destinationPath}.agentlas-${process.pid}-${crypto.randomUUID()}.bak`;
7905
+ fs.renameSync(destinationPath, backup);
7906
+ try {
7907
+ fs.renameSync(tempPath, destinationPath);
7908
+ } catch (error) {
7909
+ try {
7910
+ if (!fs.existsSync(destinationPath)) fs.renameSync(backup, destinationPath);
7911
+ } catch { /* preserve the original error and leave the backup recoverable */ }
7912
+ throw error;
7913
+ }
7914
+ try { fs.unlinkSync(backup); } catch { /* a harmless rollback copy may remain on locked Windows hosts */ }
7915
+ }
7916
+
7917
+ function ensureAgentlasProjectStateIgnoreCli(projectPath) {
7918
+ const root = terminalProjectCandidateCli(projectPath);
7919
+ if (!root) throw new Error("refusing to initialize an unsafe Agentlas project root");
7920
+ const stateDir = path.join(root, ".agentlas");
7921
+ let stateExists = false;
7922
+ try {
7923
+ const state = fs.lstatSync(stateDir);
7924
+ stateExists = true;
7925
+ if (state.isSymbolicLink() || !state.isDirectory()) throw new Error(".agentlas must be a real directory");
7926
+ assertNoSymlinkInAgentlasStateCli(stateDir);
7927
+ } catch (error) {
7928
+ if (error && error.code !== "ENOENT") throw error;
7929
+ }
7930
+
7931
+ const gitignorePath = path.join(root, ".gitignore");
7932
+ const snapshot = readRegularUtf8FileNoFollowCli(gitignorePath);
7933
+ const existing = snapshot.content;
7934
+ const mode = snapshot.mode || 0o644;
7935
+
7936
+ let next = existing;
7937
+ const start = existing.indexOf(AGENTLAS_PROJECT_STATE_IGNORE_START);
7938
+ const end = start >= 0 ? existing.indexOf(AGENTLAS_PROJECT_STATE_IGNORE_END, start) : -1;
7939
+ if (start >= 0 && end >= 0) {
7940
+ const blockEnd = end + AGENTLAS_PROJECT_STATE_IGNORE_END.length;
7941
+ const block = existing.slice(start, blockEnd);
7942
+ if (!/^\.agentlas\/$/m.test(block)) {
7943
+ next = `${existing.slice(0, start)}${block.replace(AGENTLAS_PROJECT_STATE_IGNORE_START, `${AGENTLAS_PROJECT_STATE_IGNORE_START}\n.agentlas/`)}${existing.slice(blockEnd)}`;
7944
+ }
7945
+ } else {
7946
+ const block = `${AGENTLAS_PROJECT_STATE_IGNORE_START}\n.agentlas/\n${AGENTLAS_PROJECT_STATE_IGNORE_END}\n`;
7947
+ next = existing.trimEnd() ? `${existing.trimEnd()}\n\n${block}` : block;
7948
+ }
7949
+ if (next !== existing) {
7950
+ const temp = path.join(root, `.gitignore.agentlas-${process.pid}-${crypto.randomUUID()}.tmp`);
7951
+ fs.writeFileSync(temp, next.endsWith("\n") ? next : `${next}\n`, { encoding: "utf8", mode, flag: "wx" });
7952
+ try {
7953
+ assertFileSnapshotUnchangedCli(gitignorePath, snapshot);
7954
+ replaceRegularFileCli(temp, gitignorePath, snapshot);
7955
+ } catch (error) {
7956
+ try { fs.unlinkSync(temp); } catch { /* ignore */ }
7957
+ throw error;
7958
+ }
7959
+ }
7960
+ if (!stateExists) fs.mkdirSync(stateDir, { recursive: false, mode: 0o700 });
7961
+ assertNoSymlinkInAgentlasStateCli(stateDir);
7962
+ try { fs.chmodSync(stateDir, 0o700); } catch { /* Windows/best effort */ }
7963
+ }
7964
+
7965
+ function hardenAgentlasProjectStateCli(projectPath) {
7966
+ const root = terminalProjectCandidateCli(projectPath);
7967
+ if (!root) return;
7968
+ const stateDir = path.join(root, ".agentlas");
7969
+ const pending = [stateDir];
7970
+ let visited = 0;
7971
+ while (pending.length && visited < 4096) {
7972
+ const current = pending.pop();
7973
+ visited += 1;
7974
+ try {
7975
+ const stat = fs.lstatSync(current);
7976
+ if (stat.isSymbolicLink()) continue;
7977
+ if (stat.isDirectory()) {
7978
+ try { fs.chmodSync(current, 0o700); } catch { /* Windows/best effort */ }
7979
+ for (const entry of fs.readdirSync(current)) pending.push(path.join(current, entry));
7980
+ } else if (stat.isFile()) {
7981
+ try { fs.chmodSync(current, 0o600); } catch { /* Windows/best effort */ }
7982
+ }
7983
+ } catch { /* disappearing files and ACL-only hosts are best effort */ }
7984
+ }
7985
+ }
7986
+
7987
+ function ensureCoreProjectCli(projectPath, options = {}) {
7988
+ const root = terminalProjectCandidateCli(projectPath);
7989
+ if (!root) throw new Error("Agentlas project bootstrap requires a real project directory");
7990
+ fs.accessSync(root, fs.constants.R_OK | fs.constants.W_OK);
7991
+ ensureAgentlasProjectStateIgnoreCli(root);
7992
+ const cached = projectBootstrapStates.get(root);
7993
+ if (cached && fs.existsSync(path.join(root, ".agentlas", "project-soul-memory.md"))) {
7994
+ return cached === "core";
7995
+ }
7996
+ projectBootstrapStates.delete(root);
7997
+ const coreRoot = resolveCoreRuntimeRoot(options.coreRoot);
7998
+ const hasCanonicalBootstrap = Boolean(
7999
+ coreRoot && fs.existsSync(path.join(coreRoot, "agentlas_cloud", "project_bootstrap.py")),
8000
+ );
8001
+ if (hasCanonicalBootstrap) {
8002
+ const result = captureCoreJsonSync(
8003
+ "agentlas_cloud",
8004
+ ["project", "ensure", "--project", root, "--reason", options.reason || "terminal-first-contact"],
8005
+ { cwd: root },
8006
+ coreRoot,
8007
+ );
8008
+ const canonical = Boolean(
8009
+ result
8010
+ && result.schemaVersion === "agentlas.project-bootstrap.v1"
8011
+ && ["active", "privacy_warning"].includes(result.status)
8012
+ && result.mergeOnly === true
8013
+ && result.privacyBlockInstalled === true
8014
+ && result.privateModeCompliant === true
8015
+ && Array.isArray(result.missing)
8016
+ && result.missing.length === 0
8017
+ && Array.isArray(result.overwritten)
8018
+ && result.overwritten.length === 0
8019
+ && Array.isArray(result.permissionIssues)
8020
+ && result.permissionIssues.length === 0
8021
+ );
8022
+ if (canonical) {
8023
+ // Core owns the canonical seed. Terminal adds one intentionally broader
8024
+ // guard so future local memory files are private without a release update.
8025
+ ensureAgentlasProjectStateIgnoreCli(root);
8026
+ hardenAgentlasProjectStateCli(root);
8027
+ projectBootstrapStates.set(root, "core");
8028
+ return true;
8029
+ }
8030
+ throw new Error("Agentlas Core returned an incomplete project bootstrap contract");
8031
+ }
8032
+ // A just-updated Terminal can briefly see the previous Core. The legacy
8033
+ // merge-only seed remains local-only and Core is retried next process.
8034
+ ensureProjectMemoryCli(root);
8035
+ if (!fs.existsSync(path.join(root, ".agentlas"))) {
8036
+ throw new Error("Agentlas project bootstrap could not create private local state");
8037
+ }
8038
+ ensureAgentlasProjectStateIgnoreCli(root);
8039
+ hardenAgentlasProjectStateCli(root);
8040
+ projectBootstrapStates.set(root, "fallback");
8041
+ return false;
7561
8042
  }
7562
- function cliMemoryContext(db, projectPath) {
8043
+
8044
+ // Passive checks never increment visits or touch the project. Activation is
8045
+ // reserved for an actual write/full Terminal execution or an explicit ensure.
8046
+ function recordCliFolderVisit(db, projectPath, options = {}) {
8047
+ const root = terminalProjectCandidateCli(projectPath);
8048
+ if (!root) return { activated: false };
8049
+ const activate = options.activate === true;
8050
+ try {
8051
+ if (!activate) {
8052
+ const row = tableExists(db, "folder_activity")
8053
+ ? db.prepare("SELECT activated_at FROM folder_activity WHERE path=?").get(root)
8054
+ : null;
8055
+ return { activated: Boolean(row && row.activated_at) || fs.existsSync(path.join(root, ".agentlas")) };
8056
+ }
8057
+
8058
+ ensureCoreProjectCli(root, { reason: options.reason || "terminal-first-contact", coreRoot: options.coreRoot });
8059
+ if (!tableExists(db, "folder_activity")) return { activated: true };
8060
+ const now = new Date().toISOString();
8061
+ const row = db.prepare("SELECT visits FROM folder_activity WHERE path=?").get(root);
8062
+ if (row) {
8063
+ db.prepare("UPDATE folder_activity SET visits=?, activated_at=COALESCE(activated_at,?), last_seen=? WHERE path=?")
8064
+ .run(Number(row.visits || 0) + 1, now, now, root);
8065
+ } else {
8066
+ db.prepare("INSERT INTO folder_activity (path, visits, activated_at, first_seen, last_seen) VALUES (?,?,?,?,?)")
8067
+ .run(root, 1, now, now, now);
8068
+ }
8069
+ return { activated: true };
8070
+ } catch (error) {
8071
+ // An activation failure can mean that the project-local privacy boundary
8072
+ // could not be established (for example, a symlinked or oversized
8073
+ // .gitignore). Never continue a write/full execution in that state.
8074
+ if (activate) throw error;
8075
+ return { activated: false };
8076
+ }
8077
+ }
8078
+
8079
+ function activeProjectPath(db, options = {}) {
8080
+ const root = terminalProjectCandidateCli(options.projectPath || process.cwd());
8081
+ if (!root) return null;
8082
+ const result = recordCliFolderVisit(db, root, options);
8083
+ return result.activated ? root : null;
8084
+ }
8085
+
8086
+ function ensureTerminalProjectForExecutionCli(db, projectPath, permission = PERMISSION, reason = "terminal-first-contact") {
8087
+ const root = terminalProjectCandidateCli(projectPath);
8088
+ if (!root) return null;
8089
+ if (permission === "read") return activeProjectPath(db, { projectPath: root });
8090
+ return activeProjectPath(db, { projectPath: root, activate: true, reason });
8091
+ }
8092
+ function cliMemoryContext(db, projectPath, agentId = null) {
7563
8093
  const sections = [];
7564
8094
  const arch = loadArch();
7565
8095
  ensureMemoryContextColumn(db);
@@ -7575,14 +8105,49 @@ function cliMemoryContext(db, projectPath) {
7575
8105
  }
7576
8106
  if (tableExists(db, "memory_entries")) {
7577
8107
  try {
7578
- const rows = projectPath
7579
- ? db.prepare("SELECT kind, content, context_json FROM memory_entries WHERE superseded_at IS NULL AND scope!='session' AND (project_path=? OR (project_path IS NULL AND scope IN ('user_identity','team_memory','agent_team'))) ORDER BY created_at DESC LIMIT 12").all(projectPath)
7580
- : db.prepare("SELECT kind, content, context_json FROM memory_entries WHERE project_path IS NULL AND scope!='session' AND superseded_at IS NULL ORDER BY created_at DESC LIMIT 12").all();
7581
- if (rows.length) sections.push((projectPath ? "### Recent curated memory\n" : "### Curated memory (global)\n") + rows.map((r) => `- [${r.kind}] ${r.content}${contextLine(r.context_json)}`).join("\n"));
8108
+ // New writes are read through a scoped global<->project timeline. The
8109
+ // project key is a digest, and team/agent lanes additionally require the
8110
+ // current owner id, so project B cannot recall project A's local memory.
8111
+ const governed = terminalMemoryGovernance.listScopedTimeline(db, {
8112
+ projectPath,
8113
+ agentId,
8114
+ limit: 16,
8115
+ });
8116
+ const seen = new Set(governed.map((row) => row.id));
8117
+ // Legacy rows predate the timeline. Keep only intentional user-global
8118
+ // rows, this exact project, and this exact agent/team owner. In
8119
+ // particular, do not revive the old global team-memory leakage query.
8120
+ const legacy = projectPath
8121
+ ? db.prepare(`
8122
+ SELECT id,kind,content,context_json,created_at
8123
+ FROM memory_entries
8124
+ WHERE superseded_at IS NULL AND (
8125
+ (scope='user_identity' AND project_path IS NULL)
8126
+ OR (scope='project' AND project_path=?)
8127
+ OR (scope IN ('team_memory','agent_team','agent_repo') AND agent_id=? AND (project_path IS NULL OR project_path=?))
8128
+ )
8129
+ ORDER BY created_at DESC LIMIT 16
8130
+ `).all(projectPath, agentId, projectPath)
8131
+ : db.prepare(`
8132
+ SELECT id,kind,content,context_json,created_at
8133
+ FROM memory_entries
8134
+ WHERE superseded_at IS NULL AND (
8135
+ (scope='user_identity' AND project_path IS NULL)
8136
+ OR (scope IN ('team_memory','agent_team','agent_repo') AND agent_id=? AND project_path IS NULL)
8137
+ )
8138
+ ORDER BY created_at DESC LIMIT 16
8139
+ `).all(agentId);
8140
+ const rows = [...governed, ...legacy.filter((row) => !seen.has(row.id))].slice(0, 16);
8141
+ if (rows.length) {
8142
+ sections.push(
8143
+ (projectPath ? "### Scoped global + current-project memory timeline\n" : "### Curated user-global memory\n") +
8144
+ rows.map((r) => `- [${r.kind}] ${r.content}${contextLine(r.context_json)}`).join("\n"),
8145
+ );
8146
+ }
7582
8147
  } catch { /* ignore */ }
7583
8148
  }
7584
8149
  if (!sections.length) return "";
7585
- return "## Agentlas memory (read before answering; five-scope + request_context recall)\n\n" + sections.join("\n\n");
8150
+ return "## Agentlas memory (read before answering; governed scope recall)\n\n" + sections.join("\n\n");
7586
8151
  }
7587
8152
  function parseMemoryEventsCli(text) {
7588
8153
  const heading = loadArch().eventsHeading;
@@ -7600,11 +8165,16 @@ function parseMemoryEventsCli(text) {
7600
8165
  function curateCliReply(db, text, ctx) {
7601
8166
  const { events, cleaned } = parseMemoryEventsCli(text);
7602
8167
  const style = require("./agentlas-style.cjs");
8168
+ if (ctx && ctx.permission === "read") return style.sanitizeAssistantText(cleaned);
7603
8169
  if (!events.length || !tableExists(db, "memory_entries")) return style.sanitizeAssistantText(cleaned);
7604
8170
  ensureMemoryContextColumn(db);
7605
8171
  const arch = loadArch();
7606
8172
  const { randomUUID } = require("node:crypto");
7607
8173
  const now = new Date().toISOString();
8174
+ const rememberCurated = (memory) => {
8175
+ if (!ctx || !Array.isArray(ctx.curatedMemories) || !memory) return;
8176
+ if (!ctx.curatedMemories.some((item) => item.id === memory.id)) ctx.curatedMemories.push(memory);
8177
+ };
7608
8178
  for (const ev of events) {
7609
8179
  const content = ev && typeof ev.content === "string" ? ev.content.trim() : "";
7610
8180
  if (!content) continue;
@@ -7620,9 +8190,16 @@ function curateCliReply(db, text, ctx) {
7620
8190
  const ppath = scope === "project" ? ctx.projectPath : null;
7621
8191
  const requestContext = normalizeRequestContext(ev, ctx, ppath);
7622
8192
  try {
7623
- const dup = db.prepare("SELECT 1 FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath);
7624
- if (dup) continue;
7625
- db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(randomUUID(), scope, kind, content, ctx.projectId || null, ppath, ctx.agentId || null, null, ev.confidence || "medium", ev.sensitivity || "internal", JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
8193
+ const dup = db.prepare("SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) LIMIT 1").get(scope, kind, content.toLowerCase(), ppath, ppath);
8194
+ if (dup) {
8195
+ rememberCurated({ ...dup, requestContext });
8196
+ continue;
8197
+ }
8198
+ const memoryId = randomUUID();
8199
+ const confidence = ev.confidence || "medium";
8200
+ const sensitivity = ev.sensitivity || "internal";
8201
+ db.prepare("INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)").run(memoryId, scope, kind, content, ctx.projectId || null, ppath, ctx.agentId || null, null, confidence, sensitivity, JSON.stringify(Array.isArray(ev.evidence_refs) ? ev.evidence_refs : []), JSON.stringify(requestContext), now);
8202
+ rememberCurated({ id: memoryId, scope, kind, content, confidence, sensitivity, requestContext });
7626
8203
  logCli(ctx.projectPath, { action: "written", scope, kind, content, request_context: requestContext, at: now });
7627
8204
  } catch { /* ignore */ }
7628
8205
  }
@@ -7641,7 +8218,50 @@ function prefsLang() {
7641
8218
  }
7642
8219
  }
7643
8220
 
7644
- function augmentSystem(db, baseSystem, ctx, withEmitter) {
8221
+ const TERMINAL_MEMORY_CORE_MAX_TOKENS = 150;
8222
+ const TERMINAL_MEMORY_CORE = [
8223
+ "## Memory governance",
8224
+ "End every completed reply with hidden `## Memory Events` plus fenced JSON:",
8225
+ '{"turn_id":"<stable-id>","observation":{"outcome":"completed","summary":"safe short outcome"},"candidates":[]}',
8226
+ "Candidates 0..N: memory_kind,content,suggested_scope,confidence.",
8227
+ "Scopes: user_global|team|agent|project|session|discard.",
8228
+ "No raw prompts/transcripts, secrets, logs, or absolute paths. Curator suggests; deterministic gates decide writes.",
8229
+ ].join("\n");
8230
+ const MEMORY_DETAIL_RE = /\b(?:remember|memory|save this|record this|memory event)\b|기억|메모리|저장해|기록해|남겨/i;
8231
+ const CREDENTIAL_INDEX_RE = /\b(?:deploy|release|billing|auth|oauth|credential|api key|secret key|cloud)\b|배포|릴리스|출시|결제|인증|자격 증명|API\s*키|시크릿|클라우드/i;
8232
+
8233
+ function approximatePromptTokens(text) {
8234
+ return Math.ceil(Buffer.byteLength(String(text || ""), "utf8") / 3);
8235
+ }
8236
+ if (approximatePromptTokens(TERMINAL_MEMORY_CORE) > TERMINAL_MEMORY_CORE_MAX_TOKENS) {
8237
+ throw new Error("Terminal always-on memory core exceeds 150 tokens");
8238
+ }
8239
+
8240
+ function memoryEmitterPromptFor(request, arch = loadArch(), turnId = null, permission = "write") {
8241
+ const stableId = String(turnId || "").replace(/[^A-Za-z0-9:._-]/g, "").slice(0, 160);
8242
+ let prompt = TERMINAL_MEMORY_CORE;
8243
+ if (stableId) prompt += `\nUse turn_id=${stableId}. permission=${permission === "read" ? "receipt-only" : "curated-write"}.`;
8244
+ if (!MEMORY_DETAIL_RE.test(String(request || ""))) return prompt;
8245
+ const kinds = Array.isArray(arch?.kinds) && arch.kinds.length ? arch.kinds.join("|") : "fact|decision|preference|risk|procedure";
8246
+ prompt += [
8247
+ "",
8248
+ `Allowed memory_kind: ${kinds}.`,
8249
+ "Global requires explicit owner authorization; suggest only, never promote.",
8250
+ "Do not emit request_context; put only a safe, short outcome in observation.",
8251
+ ].join("\n");
8252
+ return prompt;
8253
+ }
8254
+
8255
+ function credentialIndexReminderFor(request) {
8256
+ if (!CREDENTIAL_INDEX_RE.test(String(request || ""))) return "";
8257
+ return [
8258
+ "## Local credential lookup (triggered)",
8259
+ "Before saying a deploy, release, billing, auth, API, or cloud credential is missing, read `.agentlas/local-credentials.map.json` and the Local Credential Index in `.agentlas/project-soul-memory.md`.",
8260
+ "Use only env names and local relative references; never copy credential values into memory or output.",
8261
+ ].join("\n");
8262
+ }
8263
+
8264
+ function augmentSystem(db, baseSystem, ctx, withEmitter, request = "") {
7645
8265
  const arch = loadArch();
7646
8266
  let sys = baseSystem || "";
7647
8267
  // 언어/말투 지시를 맨 앞에 둔다. imported/cloud/company agents도 같은 전역 계약을 따른다.
@@ -7649,12 +8269,133 @@ function augmentSystem(db, baseSystem, ctx, withEmitter) {
7649
8269
  sys = langDirective(lang) + (sys ? "\n\n" + sys : "");
7650
8270
  const connectionSkill = loadGlobalConnectionSkill();
7651
8271
  if (connectionSkill) sys += "\n\n" + connectionSkill;
7652
- const mem = cliMemoryContext(db, ctx && ctx.projectPath);
8272
+ const mem = cliMemoryContext(db, ctx && ctx.projectPath, ctx && ctx.agentId);
7653
8273
  if (mem) sys += "\n\n" + mem;
7654
- if (withEmitter && arch.emitterBlock) sys += "\n\n" + arch.emitterBlock;
8274
+ if (withEmitter) {
8275
+ sys += "\n\n" + memoryEmitterPromptFor(request, arch, ctx && ctx.turnId, ctx && ctx.permission);
8276
+ const credentialReminder = credentialIndexReminderFor(request);
8277
+ if (credentialReminder) sys += "\n\n" + credentialReminder;
8278
+ }
7655
8279
  return sys;
7656
8280
  }
7657
8281
 
8282
+ function curatorRuntimeDirCli() {
8283
+ const root = path.join(userDataDir(), "memory-governance", "curator-runtime");
8284
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
8285
+ try { fs.chmodSync(path.dirname(root), 0o700); } catch { /* Windows/ACL-only host */ }
8286
+ try { fs.chmodSync(root, 0o700); } catch { /* Windows/ACL-only host */ }
8287
+ return root;
8288
+ }
8289
+
8290
+ function curatorRuntimeEnvCli(source = process.env) {
8291
+ // The semantic Curator has no tools and receives only pre-gated candidates.
8292
+ // Keep its process environment equally narrow: subscription CLIs can locate
8293
+ // their normal file-backed auth, but project/provider secret env is absent.
8294
+ const allowed = new Set([
8295
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP",
8296
+ "LANG", "LC_ALL", "LC_CTYPE", "TERM", "COLORTERM", "NO_COLOR",
8297
+ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME", "USERPROFILE",
8298
+ "APPDATA", "LOCALAPPDATA", "SYSTEMROOT", "SystemRoot", "COMSPEC", "ComSpec", "PATHEXT",
8299
+ ]);
8300
+ const env = {};
8301
+ for (const [key, value] of Object.entries(source || {})) {
8302
+ if (allowed.has(key) || key.startsWith("LC_")) env[key] = value;
8303
+ }
8304
+ env.AGENTLAS_MEMORY_CURATOR = "1";
8305
+ return env;
8306
+ }
8307
+
8308
+ function ensureGeminiNoToolsPolicyCli() {
8309
+ const dir = curatorRuntimeDirCli();
8310
+ const file = path.join(dir, "gemini-no-tools-policy.toml");
8311
+ const content = [
8312
+ "# Managed by Agentlas Terminal for the semantic Memory Curator.",
8313
+ "[[rule]]",
8314
+ 'toolName = "*"',
8315
+ 'decision = "deny"',
8316
+ "priority = 999",
8317
+ "",
8318
+ ].join("\n");
8319
+ let current = null;
8320
+ try { current = fs.readFileSync(file, "utf8"); } catch { /* first write */ }
8321
+ if (current !== content) {
8322
+ const temp = path.join(dir, `.gemini-no-tools-policy.${process.pid}.${crypto.randomUUID()}.tmp`);
8323
+ fs.writeFileSync(temp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
8324
+ fs.renameSync(temp, file);
8325
+ }
8326
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows/ACL-only host */ }
8327
+ return file;
8328
+ }
8329
+
8330
+ async function invokeMemoryCuratorCli(db, runtime, model, payload, systemPrompt) {
8331
+ const serialized = JSON.stringify(payload);
8332
+ if (
8333
+ terminalMemoryGovernance.hasSecret(serialized) ||
8334
+ terminalMemoryGovernance.hasAbsolutePath(serialized) ||
8335
+ terminalMemoryGovernance.hasTranscriptBody(serialized)
8336
+ ) {
8337
+ throw new Error("Memory Curator payload failed the pre-invocation privacy gate");
8338
+ }
8339
+ if (runtime.mode === "cli") {
8340
+ return captureRuntime(runtime.kind, systemPrompt, serialized, {
8341
+ cwd: curatorRuntimeDirCli(),
8342
+ env: curatorRuntimeEnvCli(),
8343
+ permission: "read",
8344
+ model: model || runtime.model || null,
8345
+ effort: "low",
8346
+ authorityMode: "no-authority",
8347
+ noToolsPolicyPath: runtime.kind === "gemini" ? ensureGeminiNoToolsPolicyCli() : null,
8348
+ outputLimitBytes: 64 * 1024,
8349
+ timeoutConfig: { idleMs: 60_000, totalMs: 120_000, killGraceMs: 2_000 },
8350
+ });
8351
+ }
8352
+ return runApi(runtime.backend, model || runtime.model, systemPrompt, serialized);
8353
+ }
8354
+
8355
+ function beginMemoryTurnCli(db, prompt, ctx = {}) {
8356
+ return terminalMemoryGovernance.beginTurn(db, {
8357
+ prompt,
8358
+ projectPath: ctx.projectPath,
8359
+ agentId: ctx.agentId,
8360
+ permission: ctx.permission,
8361
+ surface: ctx.surface || "terminal-normal-turn",
8362
+ conversationRef: ctx.conversationRef,
8363
+ priorContextDigest: ctx.priorContextDigest,
8364
+ stableTurnId: ctx.stableTurnId || process.env.AGENTLAS_TURN_ID,
8365
+ });
8366
+ }
8367
+
8368
+ async function completeMemoryTurnCli(db, text, ctx, runtime, options = {}) {
8369
+ const turnId = ctx?.memoryTurn?.turnId || ctx?.turnId;
8370
+ const arch = loadArch();
8371
+ const runtimeInfo = runtime || {};
8372
+ const completion = {
8373
+ turnId,
8374
+ mainOutput: text,
8375
+ requestText: options.requestText,
8376
+ permission: ctx && ctx.permission,
8377
+ projectPath: ctx && ctx.projectPath,
8378
+ agentId: ctx && ctx.agentId,
8379
+ eventsHeading: arch.eventsHeading,
8380
+ outcome: options.outcome || "completed",
8381
+ coreFiles: {
8382
+ memoryDir: arch.memoryDir || ".agentlas",
8383
+ ticketFile: arch.memoryTicketsFile || "memory-tickets.jsonl",
8384
+ decisionFile: arch.curatorDecisionsFile || "curator-decisions.jsonl",
8385
+ },
8386
+ };
8387
+ if (options.invokeCurator !== false) {
8388
+ completion.invokeCurator = (payload, systemPrompt) => invokeMemoryCuratorCli(
8389
+ db,
8390
+ runtimeInfo,
8391
+ options.model || runtimeInfo.model || null,
8392
+ payload,
8393
+ systemPrompt,
8394
+ );
8395
+ }
8396
+ return terminalMemoryGovernance.completeTurn(db, completion);
8397
+ }
8398
+
7658
8399
  function loadGlobalConnectionSkill() {
7659
8400
  try {
7660
8401
  return require("../dist/electron/runtime/global-skill.js").GLOBAL_CONNECTION_SKILL || "";
@@ -7676,19 +8417,78 @@ const RUNTIME_BIN = {
7676
8417
 
7677
8418
  // 활성 런타임 → 실행 방식 결정. CLI(claude/codex/gemini) 또는 API(BYOK/Ollama).
7678
8419
  function resolveRuntime(db, override) {
8420
+ const ar = activeRuntime(db);
8421
+ const activeCli = ar && RUNTIME_BIN[ar.kind]
8422
+ ? {
8423
+ mode: "cli",
8424
+ kind: ar.kind,
8425
+ model: ar.model || null,
8426
+ capabilities: ["code", "tools", ...(ar.long_context ? ["long-context"] : [])],
8427
+ efforts: [],
8428
+ }
8429
+ : null;
7679
8430
  if (override) {
7680
- if (!RUNTIME_BIN[override]) fail(`알 없는 런타임: ${override} (claude-code|codex|gemini)`);
7681
- return { mode: "cli", kind: override };
8431
+ if (!RUNTIME_BIN[override]) fail(`Unknown runtime: ${override} (claude-code|codex|gemini)`);
8432
+ return activeCli && activeCli.kind === override ? activeCli : { mode: "cli", kind: override };
7682
8433
  }
7683
- const ar = activeRuntime(db);
7684
- if (ar && RUNTIME_BIN[ar.kind]) return { mode: "cli", kind: ar.kind };
8434
+ if (activeCli) return activeCli;
7685
8435
  if (ar && ar.kind === "byok" && ar.backend) return { mode: "api", backend: ar.backend, model: ar.model };
7686
8436
  if (ar && ar.kind === "ollama") return { mode: "api", backend: "ollama", model: ar.model };
7687
8437
  // 폴백: 설치된 CLI 탐지
7688
8438
  for (const kind of Object.keys(RUNTIME_BIN)) {
7689
8439
  if (which(RUNTIME_BIN[kind])) return { mode: "cli", kind };
7690
8440
  }
7691
- fail("사용할 런타임이 없습니다. CLI(claude/codex/gemini) 설치하거나 앱에서 API 키/Ollama 설정하세요.");
8441
+ fail("No runtime is available. Install a CLI (claude/codex/gemini) or configure an API key/Ollama in the app.");
8442
+ }
8443
+
8444
+ // Build the executable runtime inventory for the parent allocator. It is
8445
+ // intentionally local to this host: a Terminal/Codex/Claude plugin never
8446
+ // pretends it can schedule a runtime that is not installed and connected here.
8447
+ function listAvailableRuntimes(db, fallbackRuntime = null) {
8448
+ const routing = require("./agentlas-workload-routing.cjs");
8449
+ const active = fallbackRuntime || resolveRuntime(db);
8450
+ const candidates = [];
8451
+ const add = (runtime) => {
8452
+ if (!runtime) return;
8453
+ const key = runtime.mode === "cli" ? `cli:${runtime.kind}` : `api:${runtime.backend}:${runtime.model || ""}`;
8454
+ if (candidates.some((item) => item.key === key)) return;
8455
+ const discovered = routing.defaultAvailableModels(runtime);
8456
+ const availableModels = [...discovered];
8457
+ if (runtime.model && !availableModels.some((model) => model.id === runtime.model)) {
8458
+ availableModels.push({
8459
+ id: runtime.model,
8460
+ tier: runtime.modelTier || runtime.tier || null,
8461
+ capabilities: runtime.capabilities || [],
8462
+ contextWindow: runtime.contextWindow || null,
8463
+ efforts: runtime.efforts || [],
8464
+ description: runtime.modelDescription || "host-selected current model",
8465
+ });
8466
+ }
8467
+ candidates.push({ ...runtime, key, availableModels });
8468
+ };
8469
+ add(active);
8470
+ for (const kind of Object.keys(RUNTIME_BIN)) {
8471
+ if (!which(RUNTIME_BIN[kind])) continue;
8472
+ add({ mode: "cli", kind });
8473
+ }
8474
+ return candidates
8475
+ .filter((runtime) => runtime.availableModels.length)
8476
+ .map(({ key, ...runtime }, index) => ({ ...runtime, runtimeId: `runtime-${index + 1}` }));
8477
+ }
8478
+
8479
+ function currentRuntimeInventoryCli(db, runtime) {
8480
+ const candidates = listAvailableRuntimes(db, runtime);
8481
+ const current = candidates.find((candidate) =>
8482
+ candidate.mode === runtime.mode &&
8483
+ (runtime.mode === "cli"
8484
+ ? candidate.kind === runtime.kind
8485
+ : candidate.backend === runtime.backend && candidate.model === runtime.model));
8486
+ if (current) return current;
8487
+ return {
8488
+ ...runtime,
8489
+ runtimeId: "runtime-current",
8490
+ availableModels: workloadRouting.defaultAvailableModels(runtime),
8491
+ };
7692
8492
  }
7693
8493
 
7694
8494
  // ── API 러너 (BYOK / Ollama) — 비스트리밍, 최종 텍스트 반환 ──
@@ -7728,14 +8528,14 @@ function normalizeCustomApiBaseUrl(raw) {
7728
8528
  try {
7729
8529
  parsed = new URL(value);
7730
8530
  } catch {
7731
- throw new Error("Custom API base URL 올바르지 않습니다.");
8531
+ throw new Error("Custom API base URL is invalid.");
7732
8532
  }
7733
8533
  const host = parsed.hostname.toLowerCase();
7734
8534
  const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
7735
8535
  const isPrivateLan =
7736
8536
  /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
7737
8537
  if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (isLoopback || isPrivateLan))) {
7738
- throw new Error("Custom API base URL HTTPS 또는 localhost/LAN의 HTTP여야 합니다.");
8538
+ throw new Error("Custom API base URL must use HTTPS or HTTP on localhost/LAN.");
7739
8539
  }
7740
8540
  return value.replace(/\/+$/, "");
7741
8541
  }
@@ -7761,7 +8561,7 @@ function readCustomApiBaseUrl() {
7761
8561
  raw = "";
7762
8562
  }
7763
8563
  } catch (e) {
7764
- throw new Error(`Custom API base URL 공유 DB에서 읽지 못했습니다: ${(e && e.message) || e}`);
8564
+ throw new Error(`Could not read the Custom API base URL from the shared database: ${(e && e.message) || e}`);
7765
8565
  } finally {
7766
8566
  try { if (db && typeof db.close === "function") db.close(); } catch { /* ignore close failure */ }
7767
8567
  }
@@ -7777,22 +8577,22 @@ async function runApi(backend, model, system, prompt, options) {
7777
8577
  options = options || {};
7778
8578
  model = model || DEFAULT_API_MODEL[backend];
7779
8579
  const fetchImpl = options.fetch || globalThis.fetch;
7780
- if (typeof fetchImpl !== "function") throw new Error(" 런타임에 fetch가 없습니다( 런타임으로 실행 필요).");
8580
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this runtime (run through the app runtime).");
7781
8581
  if (backend === "ollama") {
7782
8582
  const resp = await fetchImpl("http://127.0.0.1:11434/api/chat", {
7783
8583
  method: "POST",
7784
8584
  headers: { "content-type": "application/json" },
7785
8585
  body: JSON.stringify({ model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
7786
8586
  });
7787
- if (!resp.ok) throw new Error(`Ollama ${resp.status} — 'ollama serve' 실행/모델 확인`);
8587
+ if (!resp.ok) throw new Error(`Ollama ${resp.status} — run 'ollama serve' and check the model`);
7788
8588
  const j = await resp.json();
7789
8589
  return (j.message && j.message.content) || "";
7790
8590
  }
7791
8591
  const supported = backend === "anthropic" || backend === "openai" || backend === "google" ||
7792
8592
  backend === "upstage" || backend === "custom" || !!ANTHROPIC_COMPAT_API[backend];
7793
- if (!supported) throw new Error("지원하지 않는 backend: " + backend);
8593
+ if (!supported) throw new Error("Unsupported backend: " + backend);
7794
8594
  const key = Object.prototype.hasOwnProperty.call(options, "apiKey") ? options.apiKey : await apiKey(backend);
7795
- if (!key) throw new Error(`${backend} API 키가 없습니다. 설정 → BYOK에서 키를 등록하세요.`);
8595
+ if (!key) throw new Error(`${backend} API key is missing. Register it in App settings → BYOK.`);
7796
8596
 
7797
8597
  const anthropicCompat = ANTHROPIC_COMPAT_API[backend];
7798
8598
  if (backend === "anthropic" || anthropicCompat) {
@@ -7840,18 +8640,129 @@ async function runApi(backend, model, system, prompt, options) {
7840
8640
  const c = j.candidates && j.candidates[0];
7841
8641
  return (c && c.content && c.content.parts && c.content.parts[0] && c.content.parts[0].text) || "";
7842
8642
  }
7843
- throw new Error("지원하지 않는 backend: " + backend);
8643
+ throw new Error("Unsupported backend: " + backend);
7844
8644
  }
7845
8645
 
7846
8646
  // 1회 실행 — CLI면 spawn(스트리밍 stdout), API면 호출 후 텍스트 출력. 종료코드 반환.
7847
8647
  // ctx = { projectPath, agentId } — 메모리 주입/큐레이션에 사용.
8648
+ function finalizeExperienceExecutionCli(db, input) {
8649
+ if (input.permission === "read") return null;
8650
+ if (!input.agentId) return null;
8651
+ let agent;
8652
+ try { agent = db.prepare("SELECT * FROM installed_agents WHERE id=?").get(input.agentId); }
8653
+ catch { return null; }
8654
+ if (!agent) return null;
8655
+ const exactBase = exactAgentBaseForExecution(db, agent, input.runtimeExperience);
8656
+ if (!exactBase) return null;
8657
+ const runtime = input.runtime || {};
8658
+ const provider = runtime.mode === "cli" ? runtime.kind : runtime.backend;
8659
+ const modelId = input.model || runtime.model || provider;
8660
+ const usage = input.usage || {};
8661
+ try {
8662
+ return terminalExperienceIntake.finalizeAgentExecution({
8663
+ db,
8664
+ userDataDir: userDataDir(),
8665
+ cwd: input.cwd || input.projectPath || projectCwd(),
8666
+ agent,
8667
+ exactBase,
8668
+ environment: { runtime: provider || "terminal", os: process.platform, arch: process.arch },
8669
+ model: { provider: provider || "terminal-runtime", modelId: modelId || "terminal-runtime" },
8670
+ mcp: (input.mcpServers || []).flatMap((server) => {
8671
+ const catalogId = server.catalog_id || server.catalogId;
8672
+ // A reviewed runtime allowlist proves approval, not that this turn's
8673
+ // child completed an MCP initialize/tool call. Do not inflate it into
8674
+ // connected evidence without an exact runtime signal.
8675
+ return catalogId ? [{ catalogId, status: "approved" }] : [];
8676
+ }),
8677
+ outcome: input.outcome,
8678
+ metrics: {
8679
+ promptTokens: usage.input_tokens || usage.prompt_tokens || 0,
8680
+ completionTokens: usage.output_tokens || usage.completion_tokens || 0,
8681
+ totalTokens: usage.total_tokens || 0,
8682
+ durationMs: input.durationMs || usage.duration_ms || 0,
8683
+ retryCount: 0,
8684
+ },
8685
+ curatedMemories: input.curatedMemories || [],
8686
+ taskHint: input.taskHint,
8687
+ taskSignatures: input.runtimeExperience?.taskSignatures || [],
8688
+ experiencePackReleaseId: input.runtimeExperience?.experiencePackReleaseIds?.[0] || null,
8689
+ locale: input.lang || prefsLang(),
8690
+ runId: input.runId,
8691
+ createdAt: input.createdAt,
8692
+ });
8693
+ } catch (error) {
8694
+ process.stderr.write(`▸ local Experience intake skipped · ${String((error && error.message) || error).slice(0, 180)}\n`);
8695
+ return null;
8696
+ }
8697
+ }
8698
+
7848
8699
  async function executeOnce(db, system, prompt, override, ctx) {
7849
8700
  ctx = ctx || { projectPath: null, agentId: null };
8701
+ const runStartedAt = Date.now();
8702
+ const memoryTurn = beginMemoryTurnCli(db, prompt, {
8703
+ ...ctx,
8704
+ surface: ctx.surface || "terminal-one-shot",
8705
+ stableTurnId: ctx.turnId,
8706
+ });
8707
+ ctx.memoryTurn = memoryTurn;
8708
+ ctx.turnId = memoryTurn.turnId;
8709
+ const experienceRunId = `terminal-run:${memoryTurn.turnId}`;
8710
+ const curatedMemories = [];
8711
+ ctx.curatedMemories = curatedMemories;
7850
8712
  if (!ctx.cwdAtRequest) ctx.cwdAtRequest = projectCwd();
8713
+ let memoryRuntime = null;
8714
+ let memorySettled = false;
8715
+ try {
8716
+ let runtimeSystem = system;
8717
+ let localExperienceContext = null;
8718
+ if (ctx.runtimeExperience?.disabled === true && ctx.runtimeExperience.observableReason) {
8719
+ process.stderr.write(`▸ local Experience skipped · ${ctx.runtimeExperience.observableReason}\n`);
8720
+ } else if (ctx.runtimeExperience && ctx.runtimeExperience.disabled !== true) {
8721
+ const runtimeExperience = ctx.runtimeExperience;
8722
+ const augmented = terminalExperienceExchange.augmentRuntimeSystemWithLocalExperience(system, {
8723
+ userDataDir: userDataDir(),
8724
+ cwd: ctx.projectPath || ctx.cwdAtRequest,
8725
+ baseAgentReleaseId: runtimeExperience.baseAgentReleaseId,
8726
+ agentDefinitionId: runtimeExperience.agentDefinitionId,
8727
+ experiencePackReleaseIds: runtimeExperience.experiencePackReleaseIds || [],
8728
+ taskSignatures: runtimeExperience.taskSignatures || [],
8729
+ environmentTags: Array.isArray(runtimeExperience.environmentTags) && runtimeExperience.environmentTags.length
8730
+ ? runtimeExperience.environmentTags
8731
+ : terminalExperienceExchange.defaultEnvironmentTags(),
8732
+ reservedTokens: ctx.runtimeExperience?.tasteRuntimeOverlay?.estimatedTokens ?? 0,
8733
+ });
8734
+ runtimeSystem = augmented.systemPrompt;
8735
+ localExperienceContext = augmented.experienceContext;
8736
+ if (localExperienceContext.itemIds.length) {
8737
+ const source = runtimeExperience.loadoutAuthority === "desktop-terminal-exact-loadout"
8738
+ ? "Desktop-approved exact Experience"
8739
+ : "local Experience advisory";
8740
+ process.stderr.write(`▸ ${source} · ${localExperienceContext.itemIds.length} item(s) · ~${localExperienceContext.estimatedTokens} tokens · no server rental receipt\n`);
8741
+ }
8742
+ }
8743
+ const tasteTaskResolution = terminalExperienceExchange.deriveCanonicalTaskClasses(prompt);
8744
+ if (
8745
+ ctx.runtimeExperience?.tasteRuntimeOverlay &&
8746
+ desktopOntologyLoadout.tasteRuntimeOverlayMatchesTask(
8747
+ ctx.runtimeExperience.tasteRuntimeOverlay,
8748
+ tasteTaskResolution.taskIds,
8749
+ prompt,
8750
+ )
8751
+ ) {
8752
+ const tasteDirective = desktopOntologyLoadout.renderTasteRuntimeDirective(
8753
+ ctx.runtimeExperience.tasteRuntimeOverlay,
8754
+ );
8755
+ runtimeSystem = `${runtimeSystem}\n\n${tasteDirective}`;
8756
+ process.stderr.write(
8757
+ `▸ Desktop-approved exact Taste · ${ctx.runtimeExperience.tasteRuntimeOverlay.releaseId} · ~${ctx.runtimeExperience.tasteRuntimeOverlay.estimatedTokens} tokens · session snapshot\n`,
8758
+ );
8759
+ }
7851
8760
  const rt = resolveRuntime(db, override);
8761
+ memoryRuntime = rt;
7852
8762
  if (rt.mode === "cli") {
7853
- // 네이티브 CLI 자체 세션을 가지므로 emitter 넣지 않고(노이즈 방지) 메모리 컨텍스트만 주입.
7854
- const sys = augmentSystem(db, system, ctx, false);
8763
+ // 네이티브 CLI에도 같은 Memory emitter 주입하되 guard가 화면의 JSON 블록을 숨긴다.
8764
+ // 큐레이터가 만든 구조화 Memory만 성공 RunReceipt 이후 Experience intake로 전달된다.
8765
+ const sys = augmentSystem(db, runtimeSystem, ctx, true, prompt);
7855
8766
  const cwd = ctx.projectPath || projectCwd();
7856
8767
  const permission = ctx.permission || "write";
7857
8768
  const env = await buildChildEnvCli(db, { ...ctx, cwd });
@@ -7859,40 +8770,300 @@ async function executeOnce(db, system, prompt, override, ctx) {
7859
8770
  // one-shot(`agentlas "작업"`)도 REPL과 동일한 리치 렌더(⏺ 툴 / └ 결과 / 토큰)로 출력한다.
7860
8771
  const { runNativeTurn } = require("./agentlas-native-host.cjs");
7861
8772
  const { Ui } = require("./agentlas-ui.cjs");
8773
+ const { makeMemoryGuard } = require("./agentlas-repl.cjs");
7862
8774
  const ui = new Ui({ lang: prefsLang() });
7863
8775
  let mcpServers = [];
7864
8776
  if (permission === "full") {
7865
- try {
7866
- mcpServers = db.prepare("SELECT id, name, transport, command, args_json, enabled FROM mcp_servers WHERE enabled=1 AND transport='stdio'").all();
7867
- } catch { /* ignore */ }
8777
+ if (Array.isArray(ctx.mcpServers)) {
8778
+ // Build's reviewed host allowlist is authoritative, including the valid
8779
+ // empty list. Never fall back to every enabled registry row.
8780
+ mcpServers = ctx.mcpServers;
8781
+ } else {
8782
+ try {
8783
+ mcpServers = terminalAssets.readConsentedSystemMcpServers(db, { userDataDir: userDataDir() });
8784
+ } catch { /* ignore */ }
8785
+ }
7868
8786
  }
7869
8787
  ui.beginTurn();
7870
- const res = await runNativeTurn({
7871
- kind: rt.kind,
7872
- bin: which(RUNTIME_BIN[rt.kind]) || RUNTIME_BIN[rt.kind],
7873
- prompt,
7874
- systemPrompt: sys,
8788
+ const memoryGuard = makeMemoryGuard(ui, loadArch().eventsHeading);
8789
+ let res;
8790
+ try {
8791
+ res = await runNativeTurn({
8792
+ kind: rt.kind,
8793
+ bin: which(RUNTIME_BIN[rt.kind]) || RUNTIME_BIN[rt.kind],
8794
+ prompt,
8795
+ systemPrompt: sys,
8796
+ cwd,
8797
+ permission,
8798
+ session: {},
8799
+ model: ctx.model || null,
8800
+ effort: ctx.effort || null,
8801
+ mcpServers,
8802
+ mcpAllowlistMode: ctx.mcpAllowlistMode,
8803
+ env,
8804
+ ui: memoryGuard,
8805
+ });
8806
+ } finally {
8807
+ ui.endTurn();
8808
+ }
8809
+ const nativeText = String(res.text || "");
8810
+ const memoryResult = await completeMemoryTurnCli(db, nativeText, ctx, rt, {
8811
+ model: ctx.model || rt.model,
8812
+ outcome: res.error ? "failed" : "succeeded",
8813
+ requestText: prompt,
8814
+ // Every failed runtime needs a deterministic receipt, but must not
8815
+ // recursively call the runtime that just failed.
8816
+ invokeCurator: !res.error,
8817
+ });
8818
+ memorySettled = true;
8819
+ for (const memory of memoryResult.curatedMemories || []) {
8820
+ if (memory && !curatedMemories.some((item) => item.id === memory.id)) curatedMemories.push(memory);
8821
+ }
8822
+ finalizeExperienceExecutionCli(db, {
8823
+ agentId: ctx.agentId,
8824
+ projectPath: ctx.projectPath,
7875
8825
  cwd,
7876
- permission,
7877
- session: {},
7878
- model: null,
7879
- effort: null,
8826
+ runtime: rt,
8827
+ permission: ctx.permission,
8828
+ model: ctx.model || rt.model,
8829
+ runtimeExperience: ctx.runtimeExperience,
7880
8830
  mcpServers,
7881
- env,
7882
- ui,
8831
+ curatedMemories,
8832
+ taskHint: prompt,
8833
+ outcome: { status: res.error ? "failed" : "succeeded", failureCode: res.error ? "runtime-error" : null },
8834
+ usage: res.usage,
8835
+ durationMs: Date.now() - runStartedAt,
8836
+ runId: experienceRunId,
8837
+ lang: ctx.lang,
7883
8838
  });
7884
- ui.endTurn();
7885
8839
  return res.error ? 1 : 0;
7886
8840
  }
7887
8841
  // API 경로 — emitter 동봉 → 답변에서 메모리 이벤트를 파싱·큐레이션하고 블록은 제거.
7888
- const sys = augmentSystem(db, system, ctx, true);
8842
+ const sys = augmentSystem(db, runtimeSystem, ctx, true, prompt);
7889
8843
  const env = await buildChildEnvCli(db, { ...ctx, cwd: ctx.cwd || projectCwd() });
7890
8844
  Object.assign(process.env, env);
7891
- process.stderr.write(`▸ ${rt.backend}${rt.model ? " · " + rt.model : ""}\n`);
7892
- const text = await runApi(rt.backend, rt.model, sys, prompt);
7893
- const cleaned = curateCliReply(db, text || "", ctx);
8845
+ const selectedModel = ctx.model || rt.model;
8846
+ process.stderr.write(`▸ ${rt.backend}${selectedModel ? " · " + selectedModel : ""}\n`);
8847
+ let text;
8848
+ try {
8849
+ text = await runApi(rt.backend, selectedModel, sys, prompt);
8850
+ } catch (error) {
8851
+ finalizeExperienceExecutionCli(db, {
8852
+ agentId: ctx.agentId,
8853
+ projectPath: ctx.projectPath,
8854
+ cwd: ctx.cwd || projectCwd(),
8855
+ runtime: rt,
8856
+ permission: ctx.permission,
8857
+ model: selectedModel,
8858
+ runtimeExperience: ctx.runtimeExperience,
8859
+ curatedMemories,
8860
+ taskHint: prompt,
8861
+ outcome: { status: "failed", failureCode: "runtime-error" },
8862
+ durationMs: Date.now() - runStartedAt,
8863
+ runId: experienceRunId,
8864
+ lang: ctx.lang,
8865
+ });
8866
+ throw error;
8867
+ }
8868
+ const memoryResult = await completeMemoryTurnCli(db, text || "", ctx, rt, {
8869
+ model: selectedModel,
8870
+ outcome: "succeeded",
8871
+ requestText: prompt,
8872
+ });
8873
+ memorySettled = true;
8874
+ for (const memory of memoryResult.curatedMemories || []) {
8875
+ if (memory && !curatedMemories.some((item) => item.id === memory.id)) curatedMemories.push(memory);
8876
+ }
8877
+ const cleaned = require("./agentlas-style.cjs").sanitizeAssistantText(memoryResult.cleaned || "");
8878
+ finalizeExperienceExecutionCli(db, {
8879
+ agentId: ctx.agentId,
8880
+ projectPath: ctx.projectPath,
8881
+ cwd: ctx.cwd || projectCwd(),
8882
+ runtime: rt,
8883
+ permission: ctx.permission,
8884
+ model: selectedModel,
8885
+ runtimeExperience: ctx.runtimeExperience,
8886
+ curatedMemories,
8887
+ taskHint: prompt,
8888
+ outcome: { status: "succeeded", failureCode: null },
8889
+ durationMs: Date.now() - runStartedAt,
8890
+ runId: experienceRunId,
8891
+ lang: ctx.lang,
8892
+ });
7894
8893
  process.stdout.write((cleaned || "").trim() + "\n");
7895
8894
  return 0;
8895
+ } catch (error) {
8896
+ if (!memorySettled) {
8897
+ try {
8898
+ await completeMemoryTurnCli(db, "", ctx, memoryRuntime, {
8899
+ model: ctx.model || memoryRuntime?.model || null,
8900
+ outcome: "failed",
8901
+ requestText: prompt,
8902
+ invokeCurator: false,
8903
+ });
8904
+ memorySettled = true;
8905
+ } catch {
8906
+ // A missing/locked DB is the only remaining case where a receipt may
8907
+ // be impossible. Preserve the original runtime error for the caller.
8908
+ }
8909
+ }
8910
+ throw error;
8911
+ }
8912
+ }
8913
+
8914
+ async function runTerminalBuilder(db, request, metadata = {}, runtimeOverride = null, cwd = projectCwd()) {
8915
+ const builder = resolveMetaBuilder(db);
8916
+ if (!builder) throw new Error("Agentlas Core Engine Meta-Agent is unavailable; Build did not start.");
8917
+ const runtime = resolveRuntime(db, runtimeOverride);
8918
+ const routingOptions = metadata.workloadRouting && typeof metadata.workloadRouting === "object"
8919
+ ? metadata.workloadRouting
8920
+ : {};
8921
+ let allocation = null;
8922
+ try {
8923
+ const currentInventory = currentRuntimeInventoryCli(db, runtime);
8924
+ const plannerSystem = workloadRouting.plannerSystemPrompt({
8925
+ language: prefsLang() === "ko" ? "Korean" : "English",
8926
+ maxTasks: 1,
8927
+ mode: "builder",
8928
+ liveRuntimeInventory: workloadRouting.runtimeInventory([currentInventory]),
8929
+ });
8930
+ let plannerText;
8931
+ if (runtime.mode === "cli") {
8932
+ const env = await buildChildEnvCli(db, { projectPath: cwd, agentId: builder.id, permission: "read", cwd });
8933
+ plannerText = await captureRuntime(runtime.kind, plannerSystem, request, {
8934
+ cwd,
8935
+ env,
8936
+ permission: "read",
8937
+ model: routingOptions.modelPin || runtime.model || null,
8938
+ effort: routingOptions.effortPin === undefined ? null : routingOptions.effortPin,
8939
+ });
8940
+ } else {
8941
+ plannerText = await runApi(runtime.backend, routingOptions.modelPin || runtime.model, plannerSystem, request);
8942
+ }
8943
+ const plan = workloadRouting.normalizePlan(plannerText, { maxTasks: 1 });
8944
+ allocation = plan && plan.tasks[0] && plan.tasks[0].allocation;
8945
+ } catch (error) {
8946
+ process.stderr.write(`▸ builder model planner fallback · ${String((error && error.message) || error).slice(0, 160)}\n`);
8947
+ }
8948
+ const currentInventory = currentRuntimeInventoryCli(db, runtime);
8949
+ const resolution = workloadRouting.resolveAllocation({
8950
+ runtime: currentInventory,
8951
+ decision: allocation,
8952
+ modelPin: routingOptions.modelPin,
8953
+ effortPin: routingOptions.effortPin,
8954
+ availableModels: currentInventory.availableModels,
8955
+ maxTier: routingOptions.maxTier || process.env.AGENTLAS_MODEL_MAX_TIER,
8956
+ });
8957
+ const receipt = workloadRouting.createDecisionReceipt({
8958
+ taskId: "builder-execution",
8959
+ stage: "builder",
8960
+ decision: allocation,
8961
+ resolution,
8962
+ });
8963
+ try {
8964
+ workloadRouting.appendDecisionReceipt(receipt, path.join(userDataDir(), "model-routing-receipts.jsonl"));
8965
+ } catch (error) {
8966
+ process.stderr.write(`▸ builder model routing receipt failed · ${String((error && error.message) || error).slice(0, 120)}\n`);
8967
+ }
8968
+ if (!resolution.ok) {
8969
+ throw new Error(`Agentlas builder model allocation failed closed: ${resolution.fallbackReason || "no compliant live model"}`);
8970
+ }
8971
+ process.stderr.write(
8972
+ `▸ builder model route · ${resolution.source} · ${resolution.model || runtime.kind || runtime.backend}` +
8973
+ `${resolution.effort ? ` · ${resolution.effort}` : ""}` +
8974
+ `${resolution.fallbackReason ? ` · ${resolution.fallbackReason}` : ""}\n`,
8975
+ );
8976
+ const code = await executeOnce(db, agentSystemPromptCli(builder), request, runtimeOverride, {
8977
+ projectPath: cwd,
8978
+ agentId: builder.id,
8979
+ permission: "full",
8980
+ // This is an exact private host object created after consent. An empty array
8981
+ // deliberately overrides all global/project/default MCP configuration.
8982
+ mcpServers: Array.isArray(metadata.mcpServers) ? metadata.mcpServers : [],
8983
+ mcpAllowlistMode: "exact",
8984
+ model: resolution.model,
8985
+ effort: resolution.effort,
8986
+ });
8987
+ if (code !== 0) throw new Error(`Agentlas builder runtime exited ${code}`);
8988
+ return code;
8989
+ }
8990
+
8991
+ async function allocateSingleWorkloadCli(db, request, options = {}) {
8992
+ const runtime = options.runtime || resolveRuntime(db, options.runtimeOverride);
8993
+ const cwd = options.cwd || projectCwd();
8994
+ let allocation = null;
8995
+ try {
8996
+ const currentInventory = currentRuntimeInventoryCli(db, runtime);
8997
+ const plannerSystem = workloadRouting.plannerSystemPrompt({
8998
+ language: options.lang === "ko" ? "Korean" : "English",
8999
+ maxTasks: 1,
9000
+ mode: options.mode || "team",
9001
+ liveRuntimeInventory: workloadRouting.runtimeInventory([currentInventory]),
9002
+ });
9003
+ let plannerText;
9004
+ if (runtime.mode === "cli") {
9005
+ const env = await buildChildEnvCli(db, {
9006
+ projectPath: options.projectPath || null,
9007
+ agentId: options.agentId || null,
9008
+ permission: "read",
9009
+ cwd,
9010
+ });
9011
+ plannerText = await captureRuntime(runtime.kind, plannerSystem, request, {
9012
+ cwd,
9013
+ env,
9014
+ permission: "read",
9015
+ model: options.modelPin || runtime.model || null,
9016
+ effort: options.effortPin === undefined ? null : options.effortPin,
9017
+ });
9018
+ } else {
9019
+ plannerText = await runApi(runtime.backend, options.modelPin || runtime.model, plannerSystem, request);
9020
+ }
9021
+ const plan = workloadRouting.normalizePlan(plannerText, { maxTasks: 1 });
9022
+ allocation = plan && plan.tasks[0] && plan.tasks[0].allocation;
9023
+ } catch (error) {
9024
+ if (options.onWarning) options.onWarning(`model planner fallback: ${String((error && error.message) || error).slice(0, 160)}`);
9025
+ }
9026
+ const currentInventory = currentRuntimeInventoryCli(db, runtime);
9027
+ const resolution = workloadRouting.resolveAllocation({
9028
+ runtime: currentInventory,
9029
+ decision: allocation,
9030
+ modelPin: options.modelPin,
9031
+ effortPin: options.effortPin,
9032
+ availableModels: options.availableModels || currentInventory.availableModels,
9033
+ maxTier: options.maxTier || process.env.AGENTLAS_MODEL_MAX_TIER,
9034
+ });
9035
+ const receipt = workloadRouting.createDecisionReceipt({
9036
+ taskId: options.taskId || `${options.mode || "team"}-execution`,
9037
+ stage: options.mode || "team",
9038
+ decision: allocation,
9039
+ resolution,
9040
+ });
9041
+ try {
9042
+ workloadRouting.appendDecisionReceipt(receipt, options.receiptFile || path.join(userDataDir(), "model-routing-receipts.jsonl"));
9043
+ } catch (error) {
9044
+ if (options.onWarning) options.onWarning(`model routing receipt failed: ${String((error && error.message) || error).slice(0, 120)}`);
9045
+ }
9046
+ if (!resolution.ok) {
9047
+ throw new Error(`Agentlas model allocation failed closed: ${resolution.fallbackReason || "no compliant live model"}`);
9048
+ }
9049
+ return { allocation, resolution, receipt };
9050
+ }
9051
+
9052
+ async function probeApprovedTerminalMcp(db, server, runtimeOverride, cwd, probeOptions = {}) {
9053
+ const runtime = resolveRuntime(db, runtimeOverride);
9054
+ if (runtime.mode !== "cli") return { connected: false, reason: "runtime_incompatible" };
9055
+ const env = await buildChildEnvCli(db, { cwd, permission: "full" });
9056
+ if (runtime.kind === "gemini") {
9057
+ const readiness = require("./agentlas-native-host.cjs").geminiMcpIsolationReadiness(env);
9058
+ if (!readiness.ready) return { connected: false, reason: "runtime_isolation_unavailable" };
9059
+ }
9060
+ return terminalAssets.probeSystemMcpServerConnection(server, {
9061
+ cwd,
9062
+ env,
9063
+ userDataDir: userDataDir(),
9064
+ timeoutMs: probeOptions.timeoutMs,
9065
+ signal: probeOptions.signal,
9066
+ });
7896
9067
  }
7897
9068
 
7898
9069
  // API 백엔드용 간이 대화형 REPL (네이티브 인터랙티브가 없는 BYOK/Ollama).
@@ -7901,7 +9072,7 @@ function apiRepl(db, backend, model, system, label, ctx) {
7901
9072
  ctx = ctx || { projectPath: null, agentId: null };
7902
9073
  if (!ctx.cwdAtRequest) ctx.cwdAtRequest = ctx.cwd || projectCwd();
7903
9074
  const readline = require("node:readline");
7904
- process.stderr.write(`▸ ${label} (${backend}${model ? " · " + model : ""}) — 종료: /exit\n`);
9075
+ process.stderr.write(`▸ ${label} (${backend}${model ? " · " + model : ""}) — type /exit to stop\n`);
7905
9076
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
7906
9077
  const ask = () =>
7907
9078
  rl.question("\nyou › ", async (line) => {
@@ -7909,7 +9080,7 @@ function apiRepl(db, backend, model, system, label, ctx) {
7909
9080
  if (tt === "/exit" || tt === "/quit") return rl.close();
7910
9081
  if (!tt) return ask();
7911
9082
  try {
7912
- const sys = augmentSystem(db, system, ctx, true);
9083
+ const sys = augmentSystem(db, system, ctx, true, tt);
7913
9084
  const text = await runApi(backend, model, sys, tt);
7914
9085
  const cleaned = curateCliReply(db, text || "", ctx);
7915
9086
  process.stdout.write("\n" + (cleaned || "").trim() + "\n");
@@ -7955,12 +9126,6 @@ function runCwd() {
7955
9126
  }
7956
9127
  }
7957
9128
 
7958
- function cliMcpConfigPath() {
7959
- return require("./agentlas-native-host.cjs").cliMcpConfigPath([]).file;
7960
- }
7961
-
7962
- const CODEX_PLAYWRIGHT_MCP_ARGS = require("./agentlas-native-host.cjs").codexMcpArgs([]);
7963
-
7964
9129
  // 에이전트가 실제로 실행될 작업 폴더 = 사용자가 명령을 친 현재 디렉터리(= 대상 프로젝트).
7965
9130
  // 단, home/userData/agent-cwd 같은 "프로젝트 아님" 위치면 안전한 전용 폴더로 폴백한다.
7966
9131
  function projectCwd() {
@@ -8055,13 +9220,28 @@ const PROTECTED_CHILD_ENV_KEYS_CLI = new Set([
8055
9220
  "AGENTLAS_NATIVE_IDLE_TIMEOUT_MS", "AGENTLAS_NATIVE_TOTAL_TIMEOUT_MS", "AGENTLAS_NATIVE_KILL_GRACE_MS",
8056
9221
  "AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES",
8057
9222
  ]);
8058
- function isProtectedChildEnvKeyCli(key) {
8059
- return PROTECTED_CHILD_ENV_KEYS_CLI.has(String(key || "").trim().toUpperCase());
9223
+ // 네트워크 무결성 키 — TLS 검증·프록시·CA·엔드포인트·세션. 프로젝트/에이전트 dotenv(신뢰 불가:
9224
+ // 클론한 레포에 딸려올 수 있음)로 주입되면 MITM/SSRF/세션 하이재킹이 된다. 단, 사용자 본인의
9225
+ // 전역 credentials.env와 호스트 셸 env는 신뢰하므로 그대로 허용한다. 사고 방지: 원샷 API 경로는
9226
+ // buildChildEnvCli 결과를 process.env에 병합(Object.assign)하므로, 프로젝트 .env가 부모 프로세스의
9227
+ // 클라우드 호출(세션 쿠키 동반)까지 오염시킬 수 있었다.
9228
+ const UNTRUSTED_PROTECTED_ENV_KEYS_CLI = new Set([
9229
+ "NODE_TLS_REJECT_UNAUTHORIZED", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
9230
+ "OPENSSL_CONF", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
9231
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "GRPC_PROXY", "NPM_CONFIG_PROXY",
9232
+ "AGENTLAS_SESSION", "AGENTLAS_MCP_BASE_URL", "AGENTLAS_WEB_BASE_URL", "AGENTLAS_API_BASE_URL",
9233
+ "AGENTLAS_HUB_BASE_URL", "AGENTLAS_CLOUD_BASE_URL", "OLLAMA_HOST",
9234
+ ]);
9235
+ function isProtectedChildEnvKeyCli(key, trusted) {
9236
+ const k = String(key || "").trim().toUpperCase();
9237
+ if (PROTECTED_CHILD_ENV_KEYS_CLI.has(k)) return true; // 호스트 신원/플러그인 루트 — 모든 출처 차단
9238
+ if (!trusted && UNTRUSTED_PROTECTED_ENV_KEYS_CLI.has(k)) return true; // 네트워크 무결성 — 비신뢰 출처만 차단
9239
+ return false;
8060
9240
  }
8061
- function mergeChildEnvValuesCli(target, values, overwrite) {
9241
+ function mergeChildEnvValuesCli(target, values, overwrite, trusted) {
8062
9242
  const injected = [];
8063
9243
  for (const [key, value] of Object.entries(values || {})) {
8064
- if (!value || isProtectedChildEnvKeyCli(key)) continue;
9244
+ if (!value || isProtectedChildEnvKeyCli(key, trusted)) continue;
8065
9245
  if (!overwrite && target[key]) continue;
8066
9246
  target[key] = value;
8067
9247
  injected.push(key);
@@ -8070,19 +9250,20 @@ function mergeChildEnvValuesCli(target, values, overwrite) {
8070
9250
  }
8071
9251
  async function buildChildEnvCli(db, ctx) {
8072
9252
  const env = { ...process.env };
8073
- const apply = (values, overwrite) => {
8074
- mergeChildEnvValuesCli(env, values, overwrite);
9253
+ // trusted=true: 사용자 본인의 전역 자격/볼트. trusted=false: 프로젝트·에이전트 폴더 dotenv.
9254
+ const apply = (values, overwrite, trusted) => {
9255
+ mergeChildEnvValuesCli(env, values, overwrite, trusted);
8075
9256
  };
8076
9257
  const globalCredentials = {
8077
9258
  ...readDotEnvFileCli(path.join(userDataDir(), "credentials.env")),
8078
9259
  ...readDotEnvFileCli(path.join(os.homedir(), ".agentlas", "credentials.env")),
8079
9260
  };
8080
- apply(globalCredentials, false);
8081
- if (ctx && ctx.projectPath) apply(projectScopedEnvValuesCli(globalCredentials, ctx.projectPath), true);
8082
- if (ctx && ctx.cwd) apply(readDotEnvDirCli(ctx.cwd), true);
8083
- if (ctx && ctx.projectPath) apply(readDotEnvDirCli(ctx.projectPath), true);
9261
+ apply(globalCredentials, false, true);
9262
+ if (ctx && ctx.projectPath) apply(projectScopedEnvValuesCli(globalCredentials, ctx.projectPath), true, true);
9263
+ if (ctx && ctx.cwd) apply(readDotEnvDirCli(ctx.cwd), true, false);
9264
+ if (ctx && ctx.projectPath) apply(readDotEnvDirCli(ctx.projectPath), true, false);
8084
9265
  const agentDir = agentEnvDirCli(ctx && ctx.agentId);
8085
- if (agentDir) apply(readDotEnvDirCli(agentDir), true);
9266
+ if (agentDir) apply(readDotEnvDirCli(agentDir), true, false);
8086
9267
 
8087
9268
  const mm = loadMultimodalCatalog();
8088
9269
  const settings = getMultimodalSettingsCli(db);
@@ -8091,7 +9272,7 @@ async function buildChildEnvCli(db, ctx) {
8091
9272
  if (req && req.key) keys.add(req.key);
8092
9273
  }
8093
9274
  const vaultValues = await readVaultEnvValuesCli([...keys].filter((key) => !env[key]), ctx && ctx.projectPath);
8094
- apply(vaultValues, false);
9275
+ apply(vaultValues, false, true); // 볼트는 사용자 본인 저장소 — 신뢰
8095
9276
  env.AGENTLAS_MULTIMODAL_IMAGE_PROVIDER = settings.imageProvider;
8096
9277
  env.AGENTLAS_MULTIMODAL_VIDEO_PROVIDER = settings.videoProvider;
8097
9278
  env.AGENTLAS_MULTIMODAL_AUDIO_PROVIDER = settings.audioProvider;
@@ -8100,29 +9281,89 @@ async function buildChildEnvCli(db, ctx) {
8100
9281
 
8101
9282
  // One-shot/background capture uses the same permission truth as the interactive host.
8102
9283
  // Keep its plain-output argument shape, but never duplicate the security mapping here.
8103
- function buildArgs(kind, systemPrompt, prompt, permission) {
9284
+ function buildArgs(kind, systemPrompt, prompt, permission, runtimeOptions = {}) {
8104
9285
  const native = require("./agentlas-native-host.cjs");
8105
9286
  const level = require("./agentlas-permissions.cjs").normalize(permission);
9287
+ const model = runtimeOptions.model ? String(runtimeOptions.model) : null;
9288
+ const effort = runtimeOptions.effort ? String(runtimeOptions.effort) : null;
9289
+ const noAuthority = runtimeOptions.authorityMode === "no-authority";
8106
9290
  if (kind === "claude-code") {
8107
- const perm = native.claudePermissionArgs(level);
8108
- const mcp = level === "full"
8109
- ? ["--strict-mcp-config", "--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
8110
- : native.claudeMcpIsolationArgs();
8111
- return ["-p", prompt, "--append-system-prompt", systemPrompt, ...perm, ...mcp];
9291
+ const perm = native.claudePermissionArgs(noAuthority ? "read" : level);
9292
+ // Background/capture has no one-pass reviewed server list. Full permission
9293
+ // changes tool authority, not MCP consent, so this path remains exact-empty.
9294
+ const mcp = native.claudeMcpIsolationArgs();
9295
+ const thinking = effort === "max" || effort === "xhigh" ? "Ultrathink. " : effort === "high" ? "Think hard. " : effort === "medium" ? "Think. " : "";
9296
+ const claudeEffort = effort === "minimal" ? "low" : effort === "xhigh" ? "max" : effort;
9297
+ const effortArgs = claudeEffort && claudeEffort !== "none" ? ["--effort", claudeEffort] : [];
9298
+ return [
9299
+ "-p", thinking + prompt,
9300
+ "--append-system-prompt", systemPrompt,
9301
+ ...(model ? ["--model", model] : []),
9302
+ ...effortArgs,
9303
+ ...perm,
9304
+ ...(noAuthority ? ["--tools", ""] : []),
9305
+ ...mcp,
9306
+ ];
8112
9307
  }
8113
9308
  if (kind === "codex") {
8114
- const perm = native.codexPermissionArgs(level);
8115
- const mcp = level === "full" ? CODEX_PLAYWRIGHT_MCP_ARGS : [];
8116
- return ["exec", "--skip-git-repo-check", ...perm, ...mcp, `[SYSTEM]\n${systemPrompt}\n\n${prompt}`];
9309
+ const perm = native.codexPermissionArgs(noAuthority ? "read" : level);
9310
+ const mcp = [];
9311
+ const modelArgs = model ? ["-m", model] : [];
9312
+ const effortArgs = effort ? ["-c", `model_reasoning_effort="${effort}"`] : [];
9313
+ const noAuthorityArgs = noAuthority ? [
9314
+ "--ephemeral",
9315
+ "--ignore-user-config",
9316
+ "--ignore-rules",
9317
+ "--disable", "shell_tool",
9318
+ "--disable", "unified_exec",
9319
+ "--disable", "apps",
9320
+ "--disable", "browser_use",
9321
+ "--disable", "computer_use",
9322
+ "--disable", "image_generation",
9323
+ "--disable", "workspace_dependencies",
9324
+ "--disable", "goals",
9325
+ "--disable", "memories",
9326
+ "--disable", "plugins",
9327
+ "--disable", "hooks",
9328
+ "--disable", "multi_agent",
9329
+ "--disable", "tool_suggest",
9330
+ "--json",
9331
+ ] : [];
9332
+ return ["exec", "--skip-git-repo-check", ...noAuthorityArgs, ...modelArgs, ...effortArgs, ...perm, ...mcp, `[SYSTEM]\n${systemPrompt}\n\n${prompt}`];
8117
9333
  }
8118
9334
  if (kind === "gemini") {
8119
- const perm = native.geminiPermissionArgs(level);
8120
- const mcp = level === "full" ? [] : native.geminiMcpIsolationArgs();
8121
- return ["--prompt", `[SYSTEM]\n${systemPrompt}\n\n${prompt}`, ...perm, ...mcp];
9335
+ const perm = native.geminiPermissionArgs(noAuthority ? "read" : level);
9336
+ if (noAuthority && !runtimeOptions.noToolsPolicyPath) {
9337
+ throw new Error("Gemini no-authority capture requires an explicit deny-all policy");
9338
+ }
9339
+ const noAuthorityArgs = noAuthority
9340
+ ? ["--admin-policy", String(runtimeOptions.noToolsPolicyPath)]
9341
+ : [];
9342
+ // Legacy/background capture has no structured reviewed server list. Even
9343
+ // at full permission it must stay exact-empty instead of inheriting the
9344
+ // user's global Gemini MCP definitions with the provider credential env.
9345
+ const mcp = native.geminiMcpIsolationArgs();
9346
+ return ["--prompt", `[SYSTEM]\n${systemPrompt}\n\n${prompt}`, ...(model ? ["-m", model] : []), ...perm, ...noAuthorityArgs, ...mcp];
8122
9347
  }
8123
9348
  return [prompt];
8124
9349
  }
8125
9350
 
9351
+ function codexCaptureAgentText(jsonl) {
9352
+ const completed = [];
9353
+ const latest = new Map();
9354
+ for (const line of String(jsonl || "").split(/\r?\n/)) {
9355
+ if (!line.trim()) continue;
9356
+ let event;
9357
+ try { event = JSON.parse(line); } catch { continue; }
9358
+ const item = event?.item;
9359
+ if (!item || item.type !== "agent_message" || typeof item.text !== "string") continue;
9360
+ if (event.type === "item.completed") completed.push(item.text);
9361
+ else if (event.type === "item.started" || event.type === "item.updated") latest.set(String(item.id || latest.size), item.text);
9362
+ }
9363
+ if (completed.length) return completed.join("");
9364
+ return [...latest.values()].join("");
9365
+ }
9366
+
8126
9367
  // `claude` 치면 바로 대화형 세션 뜨듯이 — 에이전트 폴더(CLAUDE.md/AGENTS.md/GEMINI.md 보유)에서
8127
9368
  // 네이티브 CLI를 인자 없이(대화형) 실행. 에이전트 페르소나는 그 폴더의 프로젝트 지시로 자동 로드. (A+B 결합)
8128
9369
  // 보스턴테리어 터미널(대화형 TUI)로 진입. agentlas 가 항상 "호스트"다 —
@@ -8145,14 +9386,23 @@ function buildHelpers(db) {
8145
9386
  return {
8146
9387
  which,
8147
9388
  RUNTIME_BIN,
8148
- augmentSystem: (db_, base, ctx, emit) => augmentSystem(db_, base, ctx, emit),
8149
- curateCliReply: (db_, text, ctx) => curateCliReply(db_, text, ctx),
9389
+ augmentSystem: (db_, base, ctx, emit, request) => augmentSystem(db_, base, ctx, emit, request),
9390
+ memoryEmitterPrompt: (request, ctx) => memoryEmitterPromptFor(
9391
+ request,
9392
+ loadArch(),
9393
+ ctx && ctx.turnId,
9394
+ ctx && ctx.permission,
9395
+ ),
9396
+ beginMemoryTurn: (db_, prompt, ctx) => beginMemoryTurnCli(db_, prompt, ctx),
9397
+ completeMemoryTurn: (db_, text, ctx, runtime, options) => completeMemoryTurnCli(db_, text, ctx, runtime, options),
8150
9398
  detectResponseLanguage: (prompt, fallback) => require("./agentlas-style.cjs").detectResponseLanguage(prompt, fallback),
8151
9399
  sanitizeAssistantText: (text) => require("./agentlas-style.cjs").sanitizeAssistantText(text),
8152
9400
  apiKey: (backend) => apiKey(backend),
8153
9401
  eventsHeading: () => loadArch().eventsHeading,
8154
9402
  defaultApiModel: (backend) => DEFAULT_API_MODEL[backend],
8155
9403
  buildChildEnv: (db_, ctx) => buildChildEnvCli(db_, ctx),
9404
+ allocateWorkload: (db_, request, ctx) => allocateSingleWorkloadCli(db_, request, ctx),
9405
+ finalizeExperienceRun: (db_, input) => finalizeExperienceExecutionCli(db_, input),
8156
9406
  multimodalStatus: (db_) => multimodalStatusCli(db_),
8157
9407
  setMultimodal: (db_, modality, providerId) => setMultimodalCli(db_, modality, providerId),
8158
9408
  resolveAgent,
@@ -8164,11 +9414,11 @@ function buildHelpers(db) {
8164
9414
  autoRouteNote: (choice, lang) => autoRouteNote(choice, lang),
8165
9415
  autoRoutePreamble: (choice, lang) => autoRoutePreamble(choice, lang),
8166
9416
  directSystemPrompt: (lang) => directSystemPrompt(lang),
8167
- cliMemoryContext: (db_, pp) => cliMemoryContext(db_, pp),
9417
+ cliMemoryContext: (db_, pp, agentId) => cliMemoryContext(db_, pp, agentId),
8168
9418
  importLocal: (db_, p) => importLocalFolderCli(db_, p),
8169
9419
  // REPL-safe public Hub install: fail()(process.exit) 대신 Error를 throw 해 REPL이 직접 렌더하게 한다.
8170
9420
  cloudInstall: async (db_, slug) => {
8171
- if (typeof fetch !== "function") throw new Error(" 런타임에 fetch가 없습니다( 런타임 필요).");
9421
+ if (typeof fetch !== "function") throw new Error("fetch is unavailable in this runtime (app runtime required).");
8172
9422
  const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
8173
9423
  const headers = { "content-type": "application/json" };
8174
9424
  const cookie = await cloudSessionCookieCli();
@@ -8181,18 +9431,18 @@ function buildHelpers(db) {
8181
9431
  body: JSON.stringify({ method: "marketplace.get_manifest", params: { name: "marketplace.get_manifest", arguments: { kind: "agent", slug } } }),
8182
9432
  });
8183
9433
  } catch (e) {
8184
- throw new Error(`Hub 연결 실패: ${(e && e.message) || e}`);
9434
+ throw new Error(`Hub connection failed: ${(e && e.message) || e}`);
8185
9435
  }
8186
9436
  if (!resp.ok) {
8187
9437
  const authHint = resp.status === 401 || resp.status === 403 ? " — 로그인이 필요합니다 (앱에서 로그인 또는 AGENTLAS_SESSION 설정)" : "";
8188
- throw new Error(`Hub 응답 ${resp.status}${authHint}`);
9438
+ throw new Error(`Hub returned HTTP ${resp.status}${authHint}`);
8189
9439
  }
8190
9440
  const json = parseHubJsonCli(resp, "marketplace.get_manifest");
8191
9441
  if (json.error) throw new Error(json.error.message || "Hub error");
8192
9442
  const listing = json.result;
8193
- if (!listing) throw new Error(`Hub에서 찾을 없음: ${slug}`);
9443
+ if (!listing) throw new Error(`Not found in Hub: ${slug}`);
8194
9444
  if (listing.delivery && listing.delivery.mode === "call_only") {
8195
- throw new Error(`이 Hub 에이전트는 call-only 자산입니다. 실행: agentlas call ${slug}`);
9445
+ throw new Error(`This Hub agent is call-only. Run: agentlas call ${slug}`);
8196
9446
  }
8197
9447
  return persistCloudListingCli(db_, listing);
8198
9448
  },
@@ -8201,7 +9451,22 @@ function buildHelpers(db) {
8201
9451
  },
8202
9452
  mcpServers: (db_) => {
8203
9453
  try {
8204
- return db_.prepare("SELECT id, name, name_en, transport, command, args_json, url, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC").all();
9454
+ const consentedIds = new Set(
9455
+ terminalAssets.readConsentedSystemMcpServers(db_, { userDataDir: userDataDir(), createRuntimeHome: false })
9456
+ .map((server) => server.id),
9457
+ );
9458
+ return db_.prepare("SELECT id, catalog_id, name, name_en, transport, command, args_json, url, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC")
9459
+ .all()
9460
+ .map((row) => {
9461
+ const runtime = terminalAssets.materializeTrustedSystemMcpServer(row, { userDataDir: userDataDir(), createRuntimeHome: false });
9462
+ Object.defineProperty(row, "runtimeEligible", { value: Boolean(runtime), enumerable: false });
9463
+ Object.defineProperty(row, "runtimeConsented", { value: Boolean(runtime && consentedIds.has(String(row.id))), enumerable: false });
9464
+ if (runtime) {
9465
+ Object.defineProperty(row, "credentialKeyNames", { value: runtime.credentialKeyNames, enumerable: false });
9466
+ if (runtime.mcpRuntimeHome) Object.defineProperty(row, "mcpRuntimeHome", { value: runtime.mcpRuntimeHome, enumerable: false });
9467
+ }
9468
+ return row;
9469
+ });
8205
9470
  } catch {
8206
9471
  return [];
8207
9472
  }
@@ -8211,11 +9476,30 @@ function buildHelpers(db) {
8211
9476
  try { return JSON.parse(fs.readFileSync(path.join(userDataDir(), "cli-sessions.json"), "utf8")) || []; } catch { return []; }
8212
9477
  },
8213
9478
  sessionsSave: (list) => {
8214
- try { fs.writeFileSync(path.join(userDataDir(), "cli-sessions.json"), JSON.stringify((list || []).slice(0, 30), null, 2), "utf8"); } catch { /* ignore */ }
9479
+ try { writeJsonPrivateAtomicCli(path.join(userDataDir(), "cli-sessions.json"), (list || []).slice(0, 30)); } catch { /* ignore */ }
8215
9480
  },
8216
9481
  // 패리티: REPL의 /storm·/swarm·/build·/route·/research 가 그대로 호출한다.
8217
9482
  stormRun: (db_, goal, ctx) => parity().stormRun(db_, goal, ctx),
8218
9483
  swarmRun: (db_, goal, ctx) => parity().swarmRun(db_, goal, ctx),
9484
+ workforceRun: (db_, goal, ctx) => workforce().workforceRun(db_, goal, ctx),
9485
+ terminalBuild: (db_, args, ctx = {}) => terminalAssets.cmdBuild({
9486
+ db: db_,
9487
+ args: Array.isArray(args) ? args : terminalAssets.tokenizeBuildCommandLine(String(args || "")),
9488
+ userDataDir: userDataDir(),
9489
+ cwd: ctx.cwd || projectCwd(),
9490
+ input: ctx.input || process.stdin,
9491
+ promptOutput: ctx.promptOutput || process.stderr,
9492
+ out: ctx.out || out,
9493
+ probeMcpServer: (server, probeOptions) => probeApprovedTerminalMcp(db_, server, null, ctx.cwd || projectCwd(), probeOptions),
9494
+ invokeBuild: (request, metadata) => runTerminalBuilder(db_, request, {
9495
+ ...metadata,
9496
+ workloadRouting: {
9497
+ modelPin: ctx.modelPin || null,
9498
+ effortPin: ctx.effortPin,
9499
+ maxTier: ctx.maxTier,
9500
+ },
9501
+ }, null, ctx.cwd || projectCwd()),
9502
+ }),
8219
9503
  hepRun: (args, opts) => parity().runHephaestusInteractive(args, opts),
8220
9504
  cloudSearch: (db_, args) => parity().cloudSearch(db_, args),
8221
9505
  careerGraphCommand: (text, ctx) => runCareerGraphNaturalCli(text, {
@@ -8236,12 +9520,14 @@ function buildHelpers(db) {
8236
9520
  return null;
8237
9521
  }
8238
9522
  },
9523
+ ensureProjectForExecution: (db_, dir, permission, reason) =>
9524
+ ensureTerminalProjectForExecutionCli(db_, dir, permission, reason || "terminal-interactive-turn"),
8239
9525
  doctor: async (db_, ui) => {
8240
9526
  ui.line("");
8241
9527
  ui.info("userData: " + userDataDir());
8242
- ui.info("db: " + (fs.existsSync(dbPath()) ? "OK" : "없음"));
9528
+ ui.info("db: " + (fs.existsSync(dbPath()) ? "OK" : "missing"));
8243
9529
  const ar = activeRuntime(db_);
8244
- ui.info("활성 런타임: " + (ar ? ar.kind : "(없음)"));
9530
+ ui.info("Active runtime: " + (ar ? ar.kind : "(none)"));
8245
9531
  // CLI 런타임: 설치 + 로그인(인증 파일) 휴리스틱
8246
9532
  const home = os.homedir();
8247
9533
  const authFiles = {
@@ -8253,17 +9539,17 @@ function buildHelpers(db) {
8253
9539
  for (const [kind, bin] of Object.entries(RUNTIME_BIN)) {
8254
9540
  const installed = !!which(bin);
8255
9541
  const authed = (authFiles[kind] || []).some(has);
8256
- ui.info(` ${kind.padEnd(12)} ${!installed ? "미설치" : authed ? "설치됨 · 로그인" : "설치됨 · 로그인 미확인"}`);
9542
+ ui.info(` ${kind.padEnd(12)} ${!installed ? "not installed" : authed ? "installed · signed in" : "installed · sign-in unverified"}`);
8257
9543
  }
8258
9544
  // BYOK 키 (keytar) + 클라우드 세션
8259
9545
  const byok = [];
8260
9546
  for (const b of ["anthropic", "openai", "google", "upstage"]) {
8261
9547
  try { if (await apiKey(b)) byok.push(b); } catch { /* keytar 미사용 */ }
8262
9548
  }
8263
- ui.info("BYOK 키: " + (byok.length ? byok.join(", ") : "(없음 설정 → BYOK)"));
9549
+ ui.info("BYOK keys: " + (byok.length ? byok.join(", ") : "(noneApp settings → BYOK)"));
8264
9550
  let cloud = false;
8265
9551
  try { cloud = !!(await cloudSessionCookieCli()); } catch { /* ignore */ }
8266
- ui.info("클라우드 세션: " + (cloud ? "로그인됨" : "로그아웃"));
9552
+ ui.info("Cloud session: " + (cloud ? "signed in" : "signed out"));
8267
9553
  },
8268
9554
  };
8269
9555
  }
@@ -8304,14 +9590,18 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
8304
9590
  const cwd = opts.cwd || runCwd();
8305
9591
  return new Promise((resolve) => {
8306
9592
  const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
8307
- const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
9593
+ const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env, {
9594
+ permission: opts.permission,
9595
+ mcpServers: [],
9596
+ mcpAllowlistMode: kind === "gemini" ? "exact" : undefined,
9597
+ });
8308
9598
  const child = spawn(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
8309
9599
  cwd,
8310
9600
  stdio: ["ignore", "inherit", "inherit"],
8311
9601
  env,
8312
9602
  });
8313
9603
  child.on("error", (err) => {
8314
- process.stderr.write(`\n실행 실패(${kind}): ${err.message}\n`);
9604
+ process.stderr.write(`\nExecution failed (${kind}): ${err.message}\n`);
8315
9605
  resolve(1);
8316
9606
  });
8317
9607
  child.on("close", (code) => resolve(code ?? 0));
@@ -8341,8 +9631,17 @@ function captureRuntime(kind, systemPrompt, prompt, opts) {
8341
9631
  let child;
8342
9632
  try {
8343
9633
  const spawnImpl = opts.spawn || spawn;
8344
- const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
8345
- child = spawnImpl(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
9634
+ const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env, {
9635
+ permission: opts.permission,
9636
+ mcpServers: [],
9637
+ mcpAllowlistMode: kind === "gemini" ? "exact" : undefined,
9638
+ });
9639
+ child = spawnImpl(bin, buildArgs(kind, systemPrompt, prompt, opts.permission, {
9640
+ model: opts.model,
9641
+ effort: opts.effort,
9642
+ authorityMode: opts.authorityMode,
9643
+ noToolsPolicyPath: opts.noToolsPolicyPath,
9644
+ }), {
8346
9645
  cwd,
8347
9646
  stdio: ["ignore", "pipe", "pipe"],
8348
9647
  env,
@@ -8453,7 +9752,11 @@ function captureRuntime(kind, systemPrompt, prompt, opts) {
8453
9752
  finishReject(new Error(`${kind} exited ${code}: ${stderr.slice(-500)}`));
8454
9753
  return;
8455
9754
  }
8456
- finishResolve(stdout.trim() || stderr.trim());
9755
+ const raw = stdout.trim() || stderr.trim();
9756
+ const captured = kind === "codex" && opts.authorityMode === "no-authority"
9757
+ ? codexCaptureAgentText(raw)
9758
+ : "";
9759
+ finishResolve(captured || raw);
8457
9760
  };
8458
9761
  onAbort = () => {
8459
9762
  const reason = opts.signal && opts.signal.reason;
@@ -8483,10 +9786,12 @@ function parity() {
8483
9786
  captureRuntime,
8484
9787
  runApi,
8485
9788
  resolveRuntime,
9789
+ listAvailableRuntimes,
8486
9790
  buildChildEnvCli,
8487
9791
  projectCwd,
8488
9792
  runCwd,
8489
9793
  userDataDir,
9794
+ modelRoutingReceiptPath: () => path.join(userDataDir(), "model-routing-receipts.jsonl"),
8490
9795
  resolveAgent,
8491
9796
  resolveFirm,
8492
9797
  listAgents,
@@ -8505,6 +9810,99 @@ function parity() {
8505
9810
  return parity._i;
8506
9811
  }
8507
9812
 
9813
+ // Agent Workforce Ontology is a separate, fail-closed route. Unlike the
9814
+ // compatibility router it gives final staffing authority to the active host
9815
+ // LLM and uses Hub MCP only for retrieval, validation, and pinned preparation.
9816
+ async function listWorkforceToolsCli({ db, roster, runtimeId, cwd, env, timeoutMs, signal }) {
9817
+ const mcp = require("./agentlas-experience-mcp.cjs");
9818
+ const servers = mcp.readConsentedSystemMcpServers(db, {
9819
+ userDataDir: userDataDir(),
9820
+ createRuntimeHome: false,
9821
+ }).slice(0, 8);
9822
+ if (!servers.length) return [];
9823
+ const deadline = Date.now() + Math.max(50, Math.min(12_000, Number(timeoutMs) || 12_000));
9824
+ const outcomes = new Array(servers.length);
9825
+ let cursor = 0;
9826
+ const worker = async () => {
9827
+ while (true) {
9828
+ const index = cursor++;
9829
+ if (index >= servers.length) return;
9830
+ const remaining = deadline - Date.now();
9831
+ if (remaining <= 0 || signal?.aborted) return;
9832
+ outcomes[index] = await mcp.probeSystemMcpServerConnection(servers[index], {
9833
+ cwd,
9834
+ env,
9835
+ userDataDir: userDataDir(),
9836
+ timeoutMs: Math.min(4_000, remaining),
9837
+ signal,
9838
+ });
9839
+ }
9840
+ };
9841
+ await Promise.all(Array.from({ length: Math.min(3, servers.length) }, () => worker()));
9842
+
9843
+ const safeId = /^[A-Za-z0-9][A-Za-z0-9_.$:/@+~-]{0,127}$/;
9844
+ const rows = [];
9845
+ for (let index = 0; index < servers.length; index += 1) {
9846
+ const server = servers[index];
9847
+ const listed = outcomes[index];
9848
+ if (!listed?.connected || !Array.isArray(listed.tools)) continue;
9849
+ for (const tool of listed.tools.slice(0, 256)) {
9850
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue;
9851
+ const declared = tool._meta?.agentlas || {};
9852
+ const toolId = typeof declared.toolId === "string" ? declared.toolId : String(tool.name || "");
9853
+ const capabilityIds = Array.isArray(declared.capabilityIds)
9854
+ ? [...new Set(declared.capabilityIds.filter((id) => /^[A-Za-z0-9][A-Za-z0-9._:/@-]{1,255}$/.test(String(id))))]
9855
+ : [];
9856
+ if (!safeId.test(toolId) || !capabilityIds.length) continue;
9857
+ const schemaJson = JSON.stringify(tool.inputSchema || {}, Object.keys(tool.inputSchema || {}).sort());
9858
+ for (const pinned of roster || []) {
9859
+ if (pinned?.permissionPolicy?.mcp?.mode !== "allowlist" || !pinned.permissionPolicy.mcp.allowedTools.includes(toolId)) continue;
9860
+ rows.push({
9861
+ slotId: pinned.slotId,
9862
+ agentReleaseId: pinned.agentReleaseId,
9863
+ permissionPolicyDigest: pinned.permissionPolicyDigest,
9864
+ provider: "mcp",
9865
+ toolId,
9866
+ serverId: server.id,
9867
+ description: "Ready consented host MCP tool",
9868
+ inputSchemaDigest: `sha256:${crypto.createHash("sha256").update(schemaJson).digest("hex")}`,
9869
+ // Terminal's one-shot native/API runners do not yet expose a proven
9870
+ // exact per-tool attachment boundary. Preserve the real tools/list
9871
+ // observation, but advertise no executable runtime instead of
9872
+ // manufacturing authority. collectToolInventory filters this row and
9873
+ // fails closed before the planner for a required capability.
9874
+ runtimeIds: [],
9875
+ selectiveEnforcement: "unavailable",
9876
+ capabilityIds,
9877
+ status: "observed-not-executable",
9878
+ });
9879
+ }
9880
+ }
9881
+ }
9882
+ return rows;
9883
+ }
9884
+
9885
+ function workforce() {
9886
+ if (!workforce._i) {
9887
+ workforce._i = require("./agentlas-workforce.cjs").create({
9888
+ captureRuntime,
9889
+ runApi,
9890
+ resolveRuntime,
9891
+ buildChildEnv: buildChildEnvCli,
9892
+ projectCwd,
9893
+ userDataDir,
9894
+ receiptFile: () => path.join(userDataDir(), "workforce-execution-receipts.jsonl"),
9895
+ cloudSessionCookie: cloudSessionCookieCli,
9896
+ fetchHub: (url, init) => fetchHubCli(url, init),
9897
+ listWorkforceTools: listWorkforceToolsCli,
9898
+ supportsWorkforceToolAuthority: async () => false,
9899
+ prefsLang,
9900
+ out,
9901
+ });
9902
+ }
9903
+ return workforce._i;
9904
+ }
9905
+
8508
9906
  // ── 명령 구현 ──────────────────────────────────────────────
8509
9907
  function cmdList(db) {
8510
9908
  const agents = listAgents(db);
@@ -8556,52 +9954,162 @@ function writeIfMissing(file, content) {
8556
9954
 
8557
9955
  function cmdCd(db, query) {
8558
9956
  const agent = resolveAgent(db, query);
8559
- if (!agent) fail(`에이전트를 찾을 없습니다: ${query}`);
9957
+ if (!agent) fail(`Agent not found: ${query}`);
8560
9958
  const folder = agentFolder(agent);
8561
9959
  ensureNativeFiles(agent, folder);
8562
9960
  // 경로만 stdout으로 (cd "$(agentlas cd seo)") — 안내는 stderr로.
8563
- process.stderr.write(`# ${agent.name} — 네이티브 CLI 컨텍스트(CLAUDE.md/AGENTS.md/GEMINI.md) 준비됨\n`);
9961
+ process.stderr.write(`# ${agent.name} — native CLI context ready (CLAUDE.md/AGENTS.md/GEMINI.md)\n`);
8564
9962
  process.stdout.write(folder + "\n");
8565
9963
  }
8566
9964
 
8567
- async function cmdRun(db, query, prompt, runtimeOverride) {
9965
+ function parseRunExperienceArgs(args) {
9966
+ const prompt = [];
9967
+ const experience = { taskSignatures: [], declaredTaskClasses: [], environmentTags: [], experiencePackReleaseIds: [] };
9968
+ let passthrough = false;
9969
+ const addList = (target, value) => {
9970
+ for (const item of String(value || "").split(",").map((entry) => entry.trim()).filter(Boolean)) {
9971
+ if (!target.includes(item)) target.push(item);
9972
+ }
9973
+ };
9974
+ for (let index = 0; index < args.length; index += 1) {
9975
+ const token = String(args[index]);
9976
+ if (passthrough) { prompt.push(token); continue; }
9977
+ if (token === "--") { passthrough = true; continue; }
9978
+ const take = () => index + 1 < args.length ? String(args[++index]) : "";
9979
+ if (token === "--experience-base-release") experience.baseAgentReleaseId = take();
9980
+ else if (token.startsWith("--experience-base-release=")) experience.baseAgentReleaseId = token.slice(26);
9981
+ else if (token === "--experience-pack-release") addList(experience.experiencePackReleaseIds, take());
9982
+ else if (token.startsWith("--experience-pack-release=")) addList(experience.experiencePackReleaseIds, token.slice(26));
9983
+ else if (token === "--experience-agent-definition") experience.agentDefinitionId = take();
9984
+ else if (token.startsWith("--experience-agent-definition=")) experience.agentDefinitionId = token.slice(30);
9985
+ else if (token === "--experience-task-signature") addList(experience.taskSignatures, take());
9986
+ else if (token.startsWith("--experience-task-signature=")) addList(experience.taskSignatures, token.slice(28));
9987
+ else if (token === "--experience-task-class") addList(experience.declaredTaskClasses, take());
9988
+ else if (token.startsWith("--experience-task-class=")) addList(experience.declaredTaskClasses, token.slice(24));
9989
+ else if (token === "--experience-environment") addList(experience.environmentTags, take());
9990
+ else if (token.startsWith("--experience-environment=")) addList(experience.environmentTags, token.slice(25));
9991
+ else if (token === "--experience-desktop-loadout") experience.desktopLoadout = true;
9992
+ else if (
9993
+ token === "--experience-loadout" || token === "--experience-loadout-file" ||
9994
+ token.startsWith("--experience-loadout=") || token.startsWith("--experience-loadout-file=")
9995
+ ) {
9996
+ throw new Error("Custom Experience loadout paths are no longer supported; use --experience-desktop-loadout.");
9997
+ }
9998
+ else if (token === "--no-experience") experience.disabled = true;
9999
+ else prompt.push(token);
10000
+ }
10001
+ return { prompt: prompt.join(" "), experience };
10002
+ }
10003
+
10004
+ function resolveRuntimeExperienceCli(agent, prompt, requested, cwd, overrides = {}) {
10005
+ const prepared = desktopOntologyLoadout.prepareDesktopLoadoutRequest({
10006
+ db: overrides.db,
10007
+ agent,
10008
+ userDataDir: overrides.userDataDir || userDataDir(),
10009
+ requested: requested || {},
10010
+ now: overrides.now,
10011
+ });
10012
+ if (prepared.mode === "skip") {
10013
+ return { disabled: true, observableReason: prepared.reason, resolution: "skipped" };
10014
+ }
10015
+ const resolved = terminalExperienceExchange.resolveRuntimeExperienceForAgent({
10016
+ userDataDir: overrides.userDataDir || userDataDir(),
10017
+ cwd,
10018
+ prompt,
10019
+ requested: prepared.requested || requested || {},
10020
+ agent,
10021
+ agentRoot: agent ? (overrides.agentRoot || agentFolder(agent)) : null,
10022
+ ...(overrides.platform ? { platform: overrides.platform } : {}),
10023
+ ...(overrides.arch ? { arch: overrides.arch } : {}),
10024
+ ...(overrides.runtime ? { runtime: overrides.runtime } : {}),
10025
+ });
10026
+ if (prepared.mode !== "resolved") return resolved;
10027
+ const authority = prepared.authority;
10028
+ const tasteRuntime = {
10029
+ tasteRuntimeOverlay: authority.tasteRuntimeOverlay || null,
10030
+ loadoutAuthority: "desktop-terminal-exact-loadout",
10031
+ projectionRevision: authority.projectionRevision,
10032
+ loadoutRevision: authority.loadoutRevision,
10033
+ };
10034
+ if (!authority.experiencePackReleaseId) {
10035
+ return {
10036
+ disabled: true,
10037
+ resolution: "desktop-loadout-taste-only",
10038
+ ...tasteRuntime,
10039
+ };
10040
+ }
10041
+ if (resolved.disabled === true) return { ...resolved, ...tasteRuntime };
10042
+ if (
10043
+ resolved.agentDefinitionId !== authority.agentDefinitionId ||
10044
+ resolved.baseAgentReleaseId !== authority.baseAgentReleaseId ||
10045
+ !Array.isArray(resolved.experiencePackReleaseIds) ||
10046
+ resolved.experiencePackReleaseIds.length !== 1 ||
10047
+ resolved.experiencePackReleaseIds[0] !== authority.experiencePackReleaseId
10048
+ ) {
10049
+ return {
10050
+ disabled: true,
10051
+ observableReason: "desktop-loadout-runtime-resolution-mismatch",
10052
+ resolution: "skipped",
10053
+ ...tasteRuntime,
10054
+ };
10055
+ }
10056
+ return {
10057
+ ...resolved,
10058
+ ...tasteRuntime,
10059
+ };
10060
+ }
10061
+
10062
+ async function cmdRun(db, query, prompt, runtimeOverride, runtimeExperience = null) {
8568
10063
  const agent = resolveAgent(db, query);
8569
10064
  if (!agent) {
8570
10065
  const routedPrompt = [query, prompt].filter(Boolean).join(" ").trim() || (await readStdin());
8571
- if (!routedPrompt || !routedPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 agentlas run \"...\" 형식으로 입력하세요.");
8572
- return cmdAutoRun(db, routedPrompt.trim(), runtimeOverride);
10066
+ if (!routedPrompt || !routedPrompt.trim()) fail("Prompt is empty. Use agentlas run <agent> \"...\" or agentlas run \"...\".");
10067
+ return cmdAutoRun(db, routedPrompt.trim(), runtimeOverride, runtimeExperience);
8573
10068
  }
8574
10069
  let userPrompt = prompt;
8575
10070
  if (!userPrompt) userPrompt = await readStdin();
8576
- if (!userPrompt || !userPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 stdin으로 전달하세요.");
10071
+ if (!userPrompt || !userPrompt.trim()) fail("Prompt is empty. Pass agentlas run <agent> \"...\" or provide it through stdin.");
8577
10072
  process.stderr.write(`▸ ${agent.name}\n`);
8578
- const code = await executeOnce(db, agentSystemPromptCli(agent), userPrompt.trim(), runtimeOverride, { projectPath: activeProjectPath(db), agentId: agent.id, permission: PERMISSION });
10073
+ const projectPath = ensureTerminalProjectForExecutionCli(db, projectCwd(), PERMISSION, "terminal-run");
10074
+ const cwd = projectPath || projectCwd();
10075
+ const resolvedExperience = resolveRuntimeExperienceCli(agent, userPrompt.trim(), runtimeExperience, cwd, { db });
10076
+ const code = await executeOnce(db, agentSystemPromptCli(agent), userPrompt.trim(), runtimeOverride, {
10077
+ projectPath, agentId: agent.id, permission: PERMISSION, runtimeExperience: resolvedExperience,
10078
+ });
8579
10079
  process.exit(code);
8580
10080
  }
8581
10081
 
8582
- async function cmdAutoRun(db, prompt, runtimeOverride) {
10082
+ async function cmdAutoRun(db, prompt, runtimeOverride, runtimeExperience = null) {
8583
10083
  const lang = prefsLang();
8584
10084
  const choice = autoRouteAgent(db, prompt, lang);
8585
- if (!choice) fail("자동 라우팅할 에이전트가 없습니다. agentlas list로 설치 상태를 확인하세요.");
10085
+ if (!choice) fail("No agent is available for automatic routing. Check installation with agentlas list.");
8586
10086
  if (choice.direct) {
8587
10087
  // 전문 에이전트 확신 없음 → 페르소나/능력 라우팅 없이 현재 런타임으로 직답.
8588
10088
  process.stderr.write(`▸ direct (no agent)\n`);
8589
10089
  process.stderr.write(` ${autoRouteNote(choice, lang)}\n`);
8590
10090
  const sys = `${autoRoutePreamble(choice, lang)}\n\n${directSystemPrompt(lang)}`;
10091
+ const projectPath = ensureTerminalProjectForExecutionCli(db, projectCwd(), PERMISSION, "terminal-auto-run");
10092
+ const cwd = projectPath || projectCwd();
10093
+ const resolvedExperience = resolveRuntimeExperienceCli(null, prompt.trim(), runtimeExperience, cwd, { db });
8591
10094
  const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
8592
- projectPath: activeProjectPath(db),
10095
+ projectPath,
8593
10096
  agentId: null,
8594
10097
  permission: PERMISSION,
10098
+ runtimeExperience: resolvedExperience,
8595
10099
  });
8596
10100
  process.exit(code);
8597
10101
  }
8598
10102
  process.stderr.write(`▸ ${choice.agent.name} (auto)\n`);
8599
10103
  process.stderr.write(` ${autoRouteNote(choice, lang)}\n`);
8600
10104
  const sys = `${autoRoutePreamble(choice, lang)}\n\n${agentSystemPromptCli(choice.agent)}`;
10105
+ const projectPath = ensureTerminalProjectForExecutionCli(db, projectCwd(), PERMISSION, "terminal-auto-run");
10106
+ const cwd = projectPath || projectCwd();
10107
+ const resolvedExperience = resolveRuntimeExperienceCli(choice.agent, prompt.trim(), runtimeExperience, cwd, { db });
8601
10108
  const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
8602
- projectPath: activeProjectPath(db),
10109
+ projectPath,
8603
10110
  agentId: choice.agent.id,
8604
10111
  permission: PERMISSION,
10112
+ runtimeExperience: resolvedExperience,
8605
10113
  });
8606
10114
  process.exit(code);
8607
10115
  }
@@ -8609,7 +10117,7 @@ async function cmdAutoRun(db, prompt, runtimeOverride) {
8609
10117
  // chat / open / 에이전트명 단독 → 네이티브 CLI 대화형 세션 (claude처럼 바로 접속)
8610
10118
  function cmdOpen(db, query, runtimeOverride) {
8611
10119
  const agent = resolveAgent(db, query);
8612
- if (!agent) fail(`에이전트를 찾을 없습니다: ${query}`);
10120
+ if (!agent) fail(`Agent not found: ${query}`);
8613
10121
  launchInteractive(db, agent, runtimeOverride);
8614
10122
  }
8615
10123
 
@@ -8644,15 +10152,37 @@ function firmSystemPrompt(db, firm) {
8644
10152
  /* ignore */
8645
10153
  }
8646
10154
  const base = (ceo && ceo.system_prompt) || `You are the CEO of ${firm.name}.`;
8647
- return `${base}\n\n[FIRM] 당신은 '${firm.name}' 회사의 CEO입니다. 사용자 명령을 부서에 위임해 처리하세요.\n조직도:\n${roster}`;
10155
+ return `${base}\n\n[FIRM] You are the CEO of '${firm.name}'. Delegate user requests to the appropriate departments.\nOrganization:\n${roster}`;
8648
10156
  }
8649
10157
  async function cmdFirm(db, query, prompt, runtimeOverride) {
8650
10158
  const firm = resolveFirm(db, query);
8651
- if (!firm) fail(`회사를 찾을 없습니다: ${query}`);
10159
+ if (!firm) fail(`Company not found: ${query}`);
8652
10160
  const sys = firmSystemPrompt(db, firm);
8653
10161
  if (prompt && prompt.trim()) {
8654
10162
  process.stderr.write(`▸ ${firm.name} CEO\n`);
8655
- const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, { projectPath: activeProjectPath(db), agentId: firm.ceo_agent_id, permission: PERMISSION });
10163
+ const runtime = resolveRuntime(db, runtimeOverride);
10164
+ const projectPath = ensureTerminalProjectForExecutionCli(db, projectCwd(), PERMISSION, "terminal-firm-run");
10165
+ const allocated = await allocateSingleWorkloadCli(db, prompt.trim(), {
10166
+ runtime,
10167
+ cwd: projectCwd(),
10168
+ projectPath,
10169
+ agentId: firm.ceo_agent_id,
10170
+ lang: prefsLang(),
10171
+ mode: "team",
10172
+ onWarning: (message) => process.stderr.write(`▸ ${message}\n`),
10173
+ });
10174
+ process.stderr.write(
10175
+ `▸ team model route · ${allocated.resolution.source} · ${allocated.resolution.model || runtime.kind || runtime.backend}` +
10176
+ `${allocated.resolution.effort ? ` · ${allocated.resolution.effort}` : ""}` +
10177
+ `${allocated.resolution.fallbackReason ? ` · ${allocated.resolution.fallbackReason}` : ""}\n`,
10178
+ );
10179
+ const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
10180
+ projectPath,
10181
+ agentId: firm.ceo_agent_id,
10182
+ permission: PERMISSION,
10183
+ model: allocated.resolution.model,
10184
+ effort: allocated.resolution.effort,
10185
+ });
8656
10186
  process.exit(code);
8657
10187
  }
8658
10188
  // 대화형 — agentlas TUI. CEO 페르소나를 system으로, 작업은 현재 폴더에서.
@@ -8662,7 +10192,7 @@ async function cmdFirm(db, query, prompt, runtimeOverride) {
8662
10192
  slug: firm.slug,
8663
10193
  label: firm.name + " CEO",
8664
10194
  system: sys,
8665
- capAgent: { name: firm.name, name_en: firm.name_en || firm.name, tagline: firm.tagline, system_prompt: sys },
10195
+ capAgent: { name: firm.name, name_en: firm.name_en || firm.name, tagline: firm.tagline, tagline_en: firm.tagline_en, entity_kind: "team", system_prompt: sys },
8666
10196
  };
8667
10197
  return launchTui(db, subject, runtimeOverride);
8668
10198
  }
@@ -8844,23 +10374,23 @@ async function cmdEnv(db) {
8844
10374
  ...readDotEnvFileCli(path.join(os.homedir(), ".agentlas", "credentials.env")),
8845
10375
  };
8846
10376
  const keys = Object.keys(fromFiles).sort();
8847
- out(`공유 env ${keys.length} (값은 표시 함, credentials.env 기준):`);
10377
+ out(`Shared env keys: ${keys.length} (values hidden; from credentials.env):`);
8848
10378
  for (const k of keys) out(` ${k}`);
8849
10379
  out("");
8850
- out("키체인 저장 키는 데스크탑 앱의 설정자격증명에서 보입니다.");
10380
+ out("Keychain entries are available in Desktop settings Credentials.");
8851
10381
  return;
8852
10382
  }
8853
10383
  const keytar = readKeytar();
8854
- if (!keytar) fail("keytar 모듈을 불러올 없습니다( 런타임으로 실행 필요).");
10384
+ if (!keytar) fail("The keytar module is unavailable (run through the app runtime).");
8855
10385
  let creds;
8856
10386
  try {
8857
10387
  creds = await keytar.findCredentials(SERVICE);
8858
10388
  } catch (e) {
8859
- fail("env 조회 실패: " + ((e && e.message) || e));
10389
+ fail("Failed to read environment settings: " + ((e && e.message) || e));
8860
10390
  return;
8861
10391
  }
8862
10392
  const keys = creds.map((c) => c.account).filter((a) => a.startsWith(ENV_PREFIX)).map((a) => a.slice(ENV_PREFIX.length));
8863
- out(`공유 env ${keys.length} (값은 표시 안 함):`);
10393
+ out(`Shared env keys: ${keys.length} (values hidden):`);
8864
10394
  for (const k of keys.sort()) out(` ${k}`);
8865
10395
  }
8866
10396
 
@@ -8889,7 +10419,7 @@ function setMultimodalCli(db, modality, providerId) {
8889
10419
  const mm = loadMultimodalCatalog();
8890
10420
  if (!["image", "video", "audio"].includes(modality)) fail("usage: agentlas multimodal set <image|video|audio> <provider-id>");
8891
10421
  const provider = mm.MULTIMODAL_PROVIDERS.find((p) => p.id === providerId && p.modality === modality);
8892
- if (!provider) fail(`provider를 찾을 없습니다: ${providerId} (${modality})`);
10422
+ if (!provider) fail(`Provider not found: ${providerId} (${modality})`);
8893
10423
  const key = modality === "image" ? "imageProvider" : modality === "video" ? "videoProvider" : "audioProvider";
8894
10424
  return saveMultimodalSettingsCli(db, { [key]: providerId });
8895
10425
  }
@@ -8952,7 +10482,7 @@ function parseUpdateFlags(args) {
8952
10482
  else if (arg === "--no-launch") flags.launch = false;
8953
10483
  else if (arg === "--url") flags.url = args[++i] || flags.url;
8954
10484
  else if (arg === "--help" || arg === "-h" || arg === "help") flags.help = true;
8955
- else fail(`알 수 없는 update 옵션: ${arg}`);
10485
+ else fail(`Unknown update option: ${arg}`);
8956
10486
  }
8957
10487
  return flags;
8958
10488
  }
@@ -9027,19 +10557,19 @@ function updateTimeoutError(kind, ms) {
9027
10557
  return updateTransferError(`AGENTLAS_UPDATE_${kind.toUpperCase()}_TIMEOUT`, message);
9028
10558
  }
9029
10559
 
9030
- function parseSafeUpdateUrl(value, label = "업데이트 URL") {
10560
+ function parseSafeUpdateUrl(value, label = "update URL") {
9031
10561
  let parsed;
9032
10562
  try {
9033
10563
  parsed = new URL(String(value || ""));
9034
10564
  } catch (error) {
9035
- throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label} 형식이 올바르지 않습니다.`, error);
10565
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label} has an invalid format.`, error);
9036
10566
  }
9037
10567
  const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
9038
10568
  if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
9039
- throw updateTransferError("AGENTLAS_UPDATE_INSECURE_URL", `${label} HTTPS여야 합니다(로컬 루프백 제외).`);
10569
+ throw updateTransferError("AGENTLAS_UPDATE_INSECURE_URL", `${label} must use HTTPS (except local loopback).`);
9040
10570
  }
9041
10571
  if (parsed.username || parsed.password) {
9042
- throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label} 사용자 정보가 포함될 수 없습니다.`);
10572
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label} cannot contain user information.`);
9043
10573
  }
9044
10574
  return parsed.toString();
9045
10575
  }
@@ -9186,7 +10716,7 @@ async function fetchDesktopRelease(url) {
9186
10716
  return await fetchUpdateMetadata(url, { signal: controller.signal });
9187
10717
  } catch (error) {
9188
10718
  const message = String((error && error.message) || error);
9189
- fail(`업데이트 확인 실패: ${message}`);
10719
+ fail(`Update check failed: ${message}`);
9190
10720
  }
9191
10721
  }
9192
10722
 
@@ -9225,19 +10755,19 @@ async function cmdUpdateStandalone(flags) {
9225
10755
  if (flags.json) {
9226
10756
  return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable: comparison == null ? null : comparison < 0, channel: "npm" }, null, 2));
9227
10757
  }
9228
- out(`현재 버전: ${currentVersion}`);
10758
+ out(`Current version: ${currentVersion}`);
9229
10759
  if (!latestVersion) {
9230
- out("npm 레지스트리에서 최신 버전을 확인하지 못했습니다 (오프라인이거나 아직 미발행).");
9231
- out("수동 업데이트: npm i -g agentlas@latest");
10760
+ out("Could not check the latest version on the npm registry (offline or not published yet).");
10761
+ out("Manual update: npm i -g agentlas@latest");
9232
10762
  return;
9233
10763
  }
9234
- out(`최신 버전: ${latestVersion}`);
10764
+ out(`Latest version: ${latestVersion}`);
9235
10765
  if (comparison == null) {
9236
- out("버전 형식을 비교하지 못했습니다. 수동 업데이트: npm i -g agentlas@latest");
10766
+ out("Could not compare version formats. Manual update: npm i -g agentlas@latest");
9237
10767
  } else if (comparison < 0) {
9238
- out("업데이트: npm i -g agentlas@latest");
10768
+ out("Update: npm i -g agentlas@latest");
9239
10769
  } else {
9240
- out("이미 최신 버전입니다.");
10770
+ out("Already on the latest version.");
9241
10771
  }
9242
10772
  }
9243
10773
 
@@ -9252,7 +10782,7 @@ async function cmdUpdate(args) {
9252
10782
  const latestVersion = String(release.version || "");
9253
10783
  const artifact = findCurrentArtifact(release);
9254
10784
  const comparison = compareSemVer(currentVersion, latestVersion);
9255
- if (comparison == null) fail(`현재/최신 버전이 SemVer 형식이 아닙니다: current=${currentVersion} latest=${latestVersion}`);
10785
+ if (comparison == null) fail(`Current/latest version is not valid SemVer: current=${currentVersion} latest=${latestVersion}`);
9256
10786
  const updateAvailable = comparison < 0;
9257
10787
  const status = {
9258
10788
  currentVersion,
@@ -9270,15 +10800,15 @@ async function cmdUpdate(args) {
9270
10800
  if (flags.json) return out(JSON.stringify(status, null, 2));
9271
10801
  out(formatUpdateSummary(status));
9272
10802
  if (flags.check) return;
9273
- if (release.ready !== true) fail("최신 릴리즈가 아직 공개 설치 가능 상태가 아닙니다.");
9274
- if (!updateAvailable && !flags.force) return out("이미 최신 버전입니다.");
9275
- if (process.platform !== "darwin") return out(" OS는 아직 자동 설치를 지원하지 않습니다. 릴리즈/다운로드 링크에서 최신 설치 파일을 받으세요.");
9276
- if (!artifact || !artifact.url) fail("현재 Mac에 맞는 DMG 찾지 못했습니다.");
10803
+ if (release.ready !== true) fail("The latest release is not ready for public installation.");
10804
+ if (!updateAvailable && !flags.force) return out("Already on the latest version.");
10805
+ if (process.platform !== "darwin") return out("Automatic installation is not supported on this OS yet. Use the release/download link above.");
10806
+ if (!artifact || !artifact.url) fail("Could not find a DMG for this Mac.");
9277
10807
  await installMacDesktopUpdate(release, artifact, flags);
9278
10808
  }
9279
10809
 
9280
10810
  function requirePath(commandPath, label) {
9281
- if (!fs.existsSync(commandPath)) fail(`업데이트에 필요한 도구가 없습니다: ${label}`);
10811
+ if (!fs.existsSync(commandPath)) fail(`Required update tool not found: ${label}`);
9282
10812
  return commandPath;
9283
10813
  }
9284
10814
 
@@ -9614,9 +11144,9 @@ async function installMacDesktopUpdate(release, artifact, flags) {
9614
11144
  const stagingPath = path.join(targetDir, `.${targetName}.installing.${transactionId}.app`);
9615
11145
 
9616
11146
  try {
9617
- out(`다운로드: ${fileName}`);
11147
+ out(`Download: ${fileName}`);
9618
11148
  await downloadUpdateFile(validatedArtifact.url, dmgPath, validatedArtifact);
9619
- out("검증: DMG, notarization, Gatekeeper");
11149
+ out("Verify: DMG, notarization, Gatekeeper");
9620
11150
  await runCommand(hdiutil, ["verify", dmgPath]);
9621
11151
  await runCommand(xcrun, ["stapler", "validate", dmgPath]);
9622
11152
  await runCommand(spctl, ["-a", "-t", "open", "--context", "context:primary-signature", "-vv", dmgPath]);
@@ -9625,16 +11155,16 @@ async function installMacDesktopUpdate(release, artifact, flags) {
9625
11155
  mountPoint = parseHdiutilMountPoint(mount.stdout);
9626
11156
  const sourceApp = mountPoint ? path.join(mountPoint, "Agentlas.app") : "";
9627
11157
  if (!sourceApp || !fs.existsSync(sourceApp)) {
9628
- throw updateTransferError("AGENTLAS_UPDATE_APP_MISSING", "DMG 안에서 Agentlas.app 찾지 못했습니다.");
11158
+ throw updateTransferError("AGENTLAS_UPDATE_APP_MISSING", "Agentlas.app was not found in the DMG.");
9629
11159
  }
9630
11160
 
9631
11161
  const installedVersion = await runCommand(plistBuddy, ["-c", "Print :CFBundleShortVersionString", path.join(sourceApp, "Contents", "Info.plist")], { capture: true });
9632
11162
  const appVersion = installedVersion.stdout.trim();
9633
11163
  if (appVersion !== String(release.version)) {
9634
- throw updateTransferError("AGENTLAS_UPDATE_VERSION_MISMATCH", `앱 버전이 릴리즈와 다릅니다: release=${release.version} app=${appVersion}`);
11164
+ throw updateTransferError("AGENTLAS_UPDATE_VERSION_MISMATCH", `App version does not match the release: release=${release.version} app=${appVersion}`);
9635
11165
  }
9636
11166
 
9637
- out("설치: 기존 Agentlas 종료 교체");
11167
+ out("Install: quit the existing Agentlas app and replace it");
9638
11168
  await runCommand(osascript, ["-e", 'tell application "Agentlas" to quit'], { capture: true, allowFailure: true });
9639
11169
  await sleep(2_000);
9640
11170
  const replacement = await replaceMacAppBundle({
@@ -9645,10 +11175,10 @@ async function installMacDesktopUpdate(release, artifact, flags) {
9645
11175
  runCommand,
9646
11176
  commands: { codesign, spctl, ditto, mv, rm },
9647
11177
  });
9648
- if (replacement.backupRetained) out(`주의: 검증된 앱은 설치됐지만 이전 백업을 지우지 못했습니다: ${replacement.backupPath}`);
11178
+ if (replacement.backupRetained) out(`Warning: the verified app was installed, but the previous app backup could not be removed: ${replacement.backupPath}`);
9649
11179
  if (fs.existsSync(lsregister)) await runCommand(lsregister, ["-f", targetApp], { allowFailure: true });
9650
11180
  if (flags.launch) await runCommand(open, ["-a", "Agentlas"], { allowFailure: true });
9651
- out(`Agentlas ${release.version} 설치 완료.`);
11181
+ out(`Agentlas ${release.version} installed.`);
9652
11182
  } finally {
9653
11183
  if (mountPoint) await runCommand(hdiutil, ["detach", mountPoint], { allowFailure: true });
9654
11184
  fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -9740,32 +11270,32 @@ function oberonScaffold(args) {
9740
11270
  };
9741
11271
  if (flags.titles) manifest.titles = oberonSampleTitles(title);
9742
11272
  fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2), "utf8");
9743
- out(`✓ 매니페스트 생성: ${outPath}`);
9744
- out(` · ${shotCount} · ${aspect} · ${manifest.provider}`);
9745
- out(` · 프롬프트를 채운 뒤: agentlas oberon render ${path.basename(outPath)}`);
9746
- out(` · 또는 에이전트로 채우기: agentlas run oberon-film-studio "30 향수 광고 트레일러"`);
9747
- if (!flags.titles) out(` · 타이틀/자막 번인 샘플 포함하려면 --titles 플래그`);
11273
+ out(`✓ Manifest created: ${outPath}`);
11274
+ out(` · ${shotCount} shots · ${aspect} · ${manifest.provider}`);
11275
+ out(` · fill prompts, then run: agentlas oberon render ${path.basename(outPath)}`);
11276
+ out(` · or use an agent: agentlas run oberon-film-studio "30-second fragrance ad trailer"`);
11277
+ if (!flags.titles) out(` · use --titles to include title/subtitle burn-in samples`);
9748
11278
  }
9749
11279
 
9750
11280
  function oberonRender(args) {
9751
11281
  const { flags, rest } = oberonParseFlags(args);
9752
- if (!rest[0]) fail("렌더할 매니페스트 경로가 필요합니다: agentlas oberon render <manifest.json>");
11282
+ if (!rest[0]) fail("A manifest path is required: agentlas oberon render <manifest.json>");
9753
11283
  const manifestPath = path.resolve(rest[0]);
9754
- if (!fs.existsSync(manifestPath)) fail(`매니페스트를 찾을 없습니다: ${manifestPath}`);
11284
+ if (!fs.existsSync(manifestPath)) fail(`Manifest not found: ${manifestPath}`);
9755
11285
 
9756
11286
  let manifest;
9757
11287
  try {
9758
11288
  manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
9759
11289
  } catch (e) {
9760
- return fail(`매니페스트 JSON 파싱 실패: ${e.message}`);
11290
+ return fail(`Failed to parse manifest JSON: ${e.message}`);
9761
11291
  }
9762
- if (!Array.isArray(manifest.shots) || !manifest.shots.length) fail("매니페스트에 shots[] 가 비어 있습니다.");
11292
+ if (!Array.isArray(manifest.shots) || !manifest.shots.length) fail("The manifest has no shots[].");
9763
11293
 
9764
11294
  const root = oberonRepoRoot();
9765
11295
  const script = path.join(root, "scripts", "render-oberon-live-request.cjs");
9766
11296
  const builtRender = path.join(root, "dist", "electron", "oberon", "render.js");
9767
- if (!fs.existsSync(script)) fail(`헤드리스 렌더 스크립트가 없습니다(패키지 앱에는 미포함): ${script}`);
9768
- if (!fs.existsSync(builtRender)) fail(`Electron 빌드가 필요합니다. 먼저: npm run build:electron (없는 파일: ${builtRender})`);
11297
+ if (!fs.existsSync(script)) fail(`Headless render script not found (not included in the packaged app): ${script}`);
11298
+ if (!fs.existsSync(builtRender)) fail(`An Electron build is required. Run npm run build:electron first (missing: ${builtRender})`);
9769
11299
 
9770
11300
  // --max-shots 등 오버라이드가 있으면 사용자 매니페스트는 그대로 두고 임시 패치본을 만든다.
9771
11301
  let reqPath = manifestPath;
@@ -9790,18 +11320,18 @@ function oberonRender(args) {
9790
11320
  if (flags["poll-ms"]) childEnv.OBERON_LIVE_POLL_MS = String(flags["poll-ms"]);
9791
11321
  if (flags.open) childEnv.OBERON_LIVE_OPEN_DELIVERY = "1";
9792
11322
 
9793
- out(`▶ Oberon 렌더: "${manifest.title}" (${manifest.shots.length}샷, 최대 ${overrides.maxShots ?? manifest.maxShots ?? 3})`);
9794
- out(` 매니페스트: ${manifestPath}`);
9795
- out(` 납품 폴더: ${deliveryDir}`);
9796
- if (manifest.titles) out(` 타이틀/자막 번인: 활성 → *_titled.mp4 추가 생성`);
11323
+ out(`▶ Oberon render: "${manifest.title}" (${manifest.shots.length} shots, max ${overrides.maxShots ?? manifest.maxShots ?? 3})`);
11324
+ out(` Manifest: ${manifestPath}`);
11325
+ out(` Delivery folder: ${deliveryDir}`);
11326
+ if (manifest.titles) out(` title/subtitle burn-in: enabledgenerating additional *_titled.mp4`);
9797
11327
 
9798
11328
  if (flags["dry-run"]) {
9799
- out("\n[dry-run] 실행할 명령:");
11329
+ out("\n[dry-run] Command to run:");
9800
11330
  out(` ${process.execPath} ${script}`);
9801
11331
  out(" env: OBERON_LIVE_VEO=1");
9802
11332
  out(` OBERON_LIVE_REQUEST_FILE=${reqPath}`);
9803
11333
  out(` OBERON_LIVE_DELIVERY_DIR=${deliveryDir}`);
9804
- out(" (full Electron · GEMINI_API_KEY/GOOGLE_CLOUD_PROJECT 볼트 필요)");
11334
+ out(" (full Electron · GEMINI_API_KEY/GOOGLE_CLOUD_PROJECT vault required)");
9805
11335
  return;
9806
11336
  }
9807
11337
 
@@ -9822,11 +11352,11 @@ function oberonRender(args) {
9822
11352
  child.stderr.on("data", (c) => process.stderr.write(c));
9823
11353
  child.on("close", (code) => {
9824
11354
  if (code === 0) {
9825
- out(`\n✓ 렌더 완료납품 폴더: ${deliveryDir}`);
11355
+ out(`\n✓ Render completedelivery folder: ${deliveryDir}`);
9826
11356
  const titled = files.filter((f) => f.kind && f.kind.startsWith("titled"));
9827
- if (titled.length) out(` 타이틀/자막 번인본: ${titled.map((f) => f.name).join(", ")}`);
11357
+ if (titled.length) out(` title/subtitle burn-in files: ${titled.map((f) => f.name).join(", ")}`);
9828
11358
  } else {
9829
- process.stderr.write(`\n✖ 렌더 실패 (exit ${code})\n`);
11359
+ process.stderr.write(`\n✖ Render failed (exit ${code})\n`);
9830
11360
  process.exitCode = code || 1;
9831
11361
  }
9832
11362
  resolve();
@@ -9884,7 +11414,7 @@ function slugifyOberon(value) {
9884
11414
  function oberonList() {
9885
11415
  const dir = path.join(userDataDir(), "oberon");
9886
11416
  if (!fs.existsSync(dir)) {
9887
- out("아직 렌더 산출물이 없습니다. agentlas oberon scaffold my.json 으로 시작하세요.");
11417
+ out("No render outputs yet. Start with agentlas oberon scaffold my.json.");
9888
11418
  return;
9889
11419
  }
9890
11420
  const entries = fs
@@ -9908,40 +11438,40 @@ function oberonList() {
9908
11438
  .sort((a, b) => b.mtime - a.mtime)
9909
11439
  .slice(0, 15);
9910
11440
  if (!entries.length) {
9911
- out("아직 렌더 산출물이 없습니다.");
11441
+ out("No render outputs yet.");
9912
11442
  return;
9913
11443
  }
9914
- out(`최근 Oberon 렌더 (${dir}):\n`);
11444
+ out(`Recent Oberon renders (${dir}):\n`);
9915
11445
  for (const e of entries) {
9916
11446
  const masters = e.files.filter((f) => /master|titled/.test(f) && /\.(mp4|mov)$/.test(f));
9917
11447
  const when = e.mtime ? new Date(e.mtime).toISOString().slice(0, 16).replace("T", " ") : "";
9918
11448
  out(` ${when} ${e.name}`);
9919
11449
  if (masters.length) out(` ${masters.join(", ")}`);
9920
11450
  }
9921
- out(`\n폴더 열기: agentlas oberon open`);
11451
+ out(`\nOpen the folder with: agentlas oberon open`);
9922
11452
  }
9923
11453
 
9924
11454
  function oberonOpen(args) {
9925
11455
  const target = args[0] ? path.resolve(args[0]) : path.join(userDataDir(), "oberon");
9926
- if (!fs.existsSync(target)) fail(`경로가 없습니다: ${target}`);
11456
+ if (!fs.existsSync(target)) fail(`Path not found: ${target}`);
9927
11457
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
9928
11458
  spawn(opener, [target], { detached: true, stdio: "ignore" }).unref();
9929
- out(`폴더 열기: ${target}`);
11459
+ out(`Opening folder: ${target}`);
9930
11460
  }
9931
11461
 
9932
11462
  function oberonHelp() {
9933
11463
  out(
9934
11464
  [
9935
- "agentlas oberon — 터미널에서 AI 필름 렌더",
11465
+ "agentlas oberon — AI film rendering from the terminal",
9936
11466
  "",
9937
11467
  " oberon scaffold [out.json] [--title T] [--aspect 16:9] [--shots N] [--titles]",
9938
- " 편집 가능한 렌더 매니페스트 생성 (--titles: 타이틀/자막 번인 샘플 포함)",
11468
+ " create an editable render manifest (--titles: include title/subtitle burn-in samples)",
9939
11469
  " oberon render <manifest.json> [--delivery DIR] [--max-shots N] [--open] [--dry-run]",
9940
- " full Electron 렌더 스폰 + 진행률 스트리밍 (GEMINI_API_KEY 볼트 필요)",
11470
+ " spawn full Electron render + stream progress (GEMINI_API_KEY vault required)",
9941
11471
  " oberon list 최근 렌더 산출물",
9942
11472
  " oberon open [path] 산출물 폴더 열기",
9943
11473
  "",
9944
- "프롬프트는 직접 채우거나, 에이전트에게: agentlas run oberon-film-studio \"30 향수 광고\"",
11474
+ "Fill prompts directly, or ask an agent: agentlas run oberon-film-studio \"30-second fragrance ad\"",
9945
11475
  ].join("\n"),
9946
11476
  );
9947
11477
  }
@@ -9965,7 +11495,7 @@ async function cmdOberon(args) {
9965
11495
  case "-h":
9966
11496
  return oberonHelp();
9967
11497
  default:
9968
- fail(`알 수 없는 oberon 하위명령: ${sub} (scaffold|render|list|open|help)`);
11498
+ fail(`Unknown oberon subcommand: ${sub} (scaffold|render|list|open|help)`);
9969
11499
  }
9970
11500
  }
9971
11501
 
@@ -9983,23 +11513,31 @@ function cmdHelp() {
9983
11513
  hdr("TALK & RUN"),
9984
11514
  " <agent> jump into a chat with one agent (e.g. agentlas seo)",
9985
11515
  " run [agent] [prompt] one-shot — omit agent to auto-route (reads stdin if no prompt)",
11516
+ " --experience-desktop-loadout use Desktop's fresh exact Operational/Taste loadout receipt",
11517
+ " --no-experience highest precedence; do not read or inject a loadout",
9986
11518
  " firm <firm> [cmd] delegate to a company's CEO (interactive if no cmd)",
9987
11519
  " chats [n] recent conversations · chat resume in REPL: /resume",
9988
11520
  "",
9989
11521
  hdr("AGENTS & HUB (Agentlas OS surface)"),
9990
11522
  " search \"<what you need>\" discover agents in the Hub + local (hep-search)",
9991
11523
  " install <slug> install an agent from the Hub (hep-cloud)",
11524
+ " plugin add <slug> install a Hub plugin (MCP servers) into this machine",
9992
11525
  " build \"<request>\" build/repair/package an agent or team (hep-build)",
9993
11526
  " upload <path> save owner-private in Agent Cloud (default) (hep-upload)",
9994
11527
  " --visibility marketplace explicit compatibility flag: publish to Hub",
9995
11528
  " connect [<sub>] wire Telegram / platforms to an agent team (hep-connect)",
9996
11529
  " import <path> import a local agent/team folder",
9997
11530
  " list installed agents/companies + active runtime",
11531
+ " experience <sub> portable Experience: list|inspect|validate|save|publish|status|export|unpublish",
11532
+ " legacy local intents require explicit legacy-* commands",
11533
+ " variant resolve local variant selection: selected|fallback|base-only|error",
9998
11534
  "",
9999
11535
  hdr("EXECUTE"),
10000
- " storm <goal> force-robust pipeline: routeverify → execute (Stormbreaker) [--research]",
11536
+ " storm <goal> Agentlas Goal+UltraCode harness: planallocate → execute → verify [--research]",
10001
11537
  " swarm <goal> emergent agent swarm — parallel workers + synthesizer [--parallel N]",
10002
- " network <request> decompose a request into an A2A task force (hep-network)",
11538
+ " network <request> host-LLM workforce ontology exact TF execute [--benchmark]",
11539
+ " workforce <request> same Agent Workforce Ontology route (explicit name)",
11540
+ " legacy-network <request> compatibility-only Hephaestus network route",
10003
11541
  " call \"a,b\" \"<ctx>\" invoke named Hub/Cloud agents (hep-call)",
10004
11542
  " browser [<sub>] real browser execution hardpoint (hep-browser)",
10005
11543
  " route \"<request>\" routing preview — which agent/pipeline would take this",
@@ -10062,7 +11600,7 @@ async function main() {
10062
11600
  runtimeOverride = argv[++i];
10063
11601
  } else if (argv[i] === "--permission" || argv[i] === "-P") {
10064
11602
  const p = (argv[++i] || "").toLowerCase();
10065
- if (!["read", "write", "full"].includes(p)) fail(`알 없는 권한: ${p} (read|write|full)`);
11603
+ if (!["read", "write", "full"].includes(p)) fail(`Unknown permission: ${p} (read|write|full)`);
10066
11604
  PERMISSION = p;
10067
11605
  PERMISSION_EXPLICIT = true;
10068
11606
  } else {
@@ -10097,8 +11635,10 @@ async function main() {
10097
11635
  return cmdImport(db, rest[1]);
10098
11636
  case "cd":
10099
11637
  return cmdCd(db, rest[1]);
10100
- case "run":
10101
- return cmdRun(db, rest[1], rest.slice(2).join(" "), runtimeOverride);
11638
+ case "run": {
11639
+ const runInput = parseRunExperienceArgs(rest.slice(2));
11640
+ return cmdRun(db, rest[1], runInput.prompt, runtimeOverride, runInput.experience);
11641
+ }
10102
11642
  case "chat":
10103
11643
  case "open":
10104
11644
  return cmdOpen(db, rest[1], runtimeOverride);
@@ -10121,10 +11661,16 @@ async function main() {
10121
11661
  return cmdCloud(db, rest.slice(1), runtimeOverride);
10122
11662
  case "creds":
10123
11663
  return cmdCreds(db, rest.slice(1));
10124
- case "storm":
10125
- return parity().cmdStorm(db, rest.slice(1), runtimeOverride);
10126
- case "swarm":
10127
- return parity().cmdSwarm(db, rest.slice(1), runtimeOverride);
11664
+ case "storm": {
11665
+ const cwd = projectCwd();
11666
+ const projectPath = ensureTerminalProjectForExecutionCli(db, cwd, PERMISSION, "terminal-storm");
11667
+ return parity().cmdStorm(db, rest.slice(1), runtimeOverride, { cwd, projectPath, permission: PERMISSION });
11668
+ }
11669
+ case "swarm": {
11670
+ const cwd = projectCwd();
11671
+ const projectPath = ensureTerminalProjectForExecutionCli(db, cwd, PERMISSION, "terminal-swarm");
11672
+ return parity().cmdSwarm(db, rest.slice(1), runtimeOverride, { cwd, projectPath, permission: PERMISSION });
11673
+ }
10128
11674
  case "automation":
10129
11675
  case "automations":
10130
11676
  return parity().cmdAutomation(db, rest.slice(1), runtimeOverride);
@@ -10133,16 +11679,54 @@ async function main() {
10133
11679
  return parity().cmdHep(db, rest.slice(1));
10134
11680
  // ── Agentlas OS 정식 표면 (hep-*) 1급 노출 ──
10135
11681
  case "build":
10136
- // hep-build "<요청>" 에이전트/팀 빌드 (Meta-Agent Factory)
10137
- return parity().cmdHep(db, rest.length > 1 ? ["hep-build", rest.slice(1).join(" ")] : ["hep-build"]);
11682
+ // Terminal-owned preflight: trusted system-global MCP metadata first, one consent,
11683
+ // then pass only approved catalog IDs/value-free shortages to the existing builder.
11684
+ ensureTerminalProjectForExecutionCli(db, projectCwd(), PERMISSION, "terminal-build");
11685
+ return terminalAssets.cmdBuild({
11686
+ db,
11687
+ args: rest.slice(1),
11688
+ userDataDir: userDataDir(),
11689
+ cwd: projectCwd(),
11690
+ input: process.stdin,
11691
+ promptOutput: process.stderr,
11692
+ out,
11693
+ probeMcpServer: (server, probeOptions) => probeApprovedTerminalMcp(db, server, runtimeOverride, projectCwd(), probeOptions),
11694
+ invokeBuild: (request, metadata) => runTerminalBuilder(db, request, metadata, runtimeOverride, projectCwd()),
11695
+ });
11696
+ case "experience":
11697
+ return terminalExperienceExchange.cmdExperienceExchange({
11698
+ args: rest.slice(1),
11699
+ userDataDir: userDataDir(),
11700
+ cwd: projectCwd(),
11701
+ out,
11702
+ env: process.env,
11703
+ getSessionCookie: cloudSessionCookieCli,
11704
+ fetchHub: (url, init) => fetchHubCli(url, init),
11705
+ legacyCommand: (legacyOptions) => terminalAssets.cmdExperience(legacyOptions),
11706
+ });
11707
+ case "variant":
11708
+ return terminalAssets.cmdVariant({
11709
+ db,
11710
+ args: rest.slice(1),
11711
+ userDataDir: userDataDir(),
11712
+ cwd: projectCwd(),
11713
+ out,
11714
+ });
10138
11715
  case "search": // hep-search — 에이전트 디렉터리 발견 (Hub + 로컬)
10139
- if (!rest[1]) return fail('usage: agentlas search "<찾는 일>" [--limit 10]');
11716
+ if (!rest[1]) return fail('usage: agentlas search "<what you need>" [--limit 10]');
10140
11717
  return parity().cloudSearch(db, rest.slice(1));
10141
11718
  case "install": // public Hub package install — slug로 에이전트 설치
10142
- if (!rest[1]) return fail('usage: agentlas install <slug> (먼저 agentlas search " " 로 찾으세요)');
11719
+ if (!rest[1]) return fail('usage: agentlas install <slug> (run agentlas search "what you need" first)');
10143
11720
  return cmdCloudInstall(db, rest[1]);
11721
+ case "plugin": // Hub 플러그인(MCP 서버 번들) — 에이전트 설치(install)와 다른 카탈로그다
11722
+ case "plugins": {
11723
+ const action = rest[1];
11724
+ if (action === "add") return cmdPluginAdd(db, rest[2]);
11725
+ if (action === "list" || !action) return cmdPluginList();
11726
+ return fail("usage: agentlas plugin add <slug> | agentlas plugin list");
11727
+ }
10144
11728
  case "upload": { // 기본은 owner-private Agent Cloud, public Hub는 명시 flag로만.
10145
- if (!rest[1]) return fail("usage: agentlas upload <에이전트 폴더 경로> [--visibility marketplace]");
11729
+ if (!rest[1]) return fail("usage: agentlas upload <agent-folder-path> [--visibility marketplace]");
10146
11730
  const uploadArgs = rest.slice(1);
10147
11731
  return cmdCloud(db, [cloudActionForTopLevelUpload(uploadArgs), ...uploadArgs], runtimeOverride);
10148
11732
  }
@@ -10152,8 +11736,14 @@ async function main() {
10152
11736
  return parity().cmdHep(db, ["hep-browser", ...rest.slice(1)]);
10153
11737
  case "call": // hep-call — 지정 에이전트 호출/준비
10154
11738
  return parity().cmdHep(db, ["hep-call", ...rest.slice(1)]);
10155
- case "network": // hep-network — A2A 태스크포스 분해/스케줄
10156
- case "taskforce":
11739
+ case "workforce":
11740
+ case "network":
11741
+ case "taskforce": {
11742
+ const cwd = projectCwd();
11743
+ const projectPath = ensureTerminalProjectForExecutionCli(db, cwd, PERMISSION, "terminal-workforce");
11744
+ return workforce().cmdWorkforce(db, rest.slice(1), runtimeOverride, { cwd, projectPath, permission: PERMISSION });
11745
+ }
11746
+ case "legacy-network": // explicit compatibility escape hatch only
10157
11747
  return parity().cmdHep(db, ["hep-network", ...rest.slice(1)]);
10158
11748
  case "route": // 라우팅 미리보기 (실행 없음)
10159
11749
  return parity().cmdHep(
@@ -10202,7 +11792,7 @@ async function main() {
10202
11792
  if (firm) return cmdFirm(db, cmd, "", runtimeOverride);
10203
11793
  const prompt = rest.join(" ").trim();
10204
11794
  if (prompt) return cmdAutoRun(db, prompt, runtimeOverride);
10205
- fail(`에이전트/회사를 찾을 없습니다: ${cmd} (agentlas list 로 확인)`);
11795
+ fail(`Agent/company not found: ${cmd} (check with agentlas list)`);
10206
11796
  }
10207
11797
  }
10208
11798
  }
@@ -10219,6 +11809,9 @@ module.exports = {
10219
11809
  parseDotEnvCli,
10220
11810
  isProtectedChildEnvKeyCli,
10221
11811
  mergeChildEnvValuesCli,
11812
+ openNodeSqliteDb,
11813
+ ensureMemoryContextColumn,
11814
+ writeJsonPrivateAtomicCli,
10222
11815
  resolveCredentialSourcePath,
10223
11816
  upsertEnvLine,
10224
11817
  fetchHubCli,
@@ -10233,6 +11826,7 @@ module.exports = {
10233
11826
  replaceMacAppBundle,
10234
11827
  captureRuntime,
10235
11828
  buildArgs,
11829
+ codexCaptureAgentText,
10236
11830
  captureOutputLimit,
10237
11831
  materializeCloudListingCli,
10238
11832
  recoverCloudInstallJournalCli,
@@ -10252,6 +11846,28 @@ module.exports = {
10252
11846
  cloudPackageHashVersion,
10253
11847
  cloudPortablePathConflict,
10254
11848
  cloudPortableExecutableForFile,
11849
+ parseRunExperienceArgs,
11850
+ resolveRuntimeExperienceCli,
11851
+ runTerminalBuilder,
11852
+ resolveRuntime,
11853
+ listAvailableRuntimes,
11854
+ probeApprovedTerminalMcp,
11855
+ finalizeExperienceExecutionCli,
11856
+ buildChildEnvCli,
11857
+ augmentSystem,
11858
+ beginMemoryTurnCli,
11859
+ completeMemoryTurnCli,
11860
+ curatorRuntimeEnvCli,
11861
+ ensureGeminiNoToolsPolicyCli,
11862
+ curateCliReply,
11863
+ TERMINAL_MEMORY_CORE,
11864
+ TERMINAL_MEMORY_CORE_MAX_TOKENS,
11865
+ approximatePromptTokens,
11866
+ memoryEmitterPromptFor,
11867
+ credentialIndexReminderFor,
11868
+ ensureCoreProjectCli,
11869
+ ensureTerminalProjectForExecutionCli,
11870
+ ensureAgentlasProjectStateIgnoreCli,
10255
11871
  DEFAULT_API_MODEL,
10256
11872
  ANTHROPIC_COMPAT_API,
10257
11873
  // 자동 라우팅 회귀 테스트 표면 — 약한 매치 직답/오라우팅 방지 규칙 검증용.