agentlas 1.0.59 → 1.0.61

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 (38) hide show
  1. package/engine/agentlas-memory-governance.cjs +88 -10
  2. package/engine/agentlas-permissions.cjs +95 -1
  3. package/engine/agentlas-tools.cjs +45 -2
  4. package/engine/agents/builder.cjs +4 -0
  5. package/engine/architecture.data.json +1 -1
  6. package/engine/bootstrap-schema.sql +188 -24
  7. package/engine/cloud-assets/cas.cjs +27 -1
  8. package/engine/cloud-assets/package.cjs +126 -0
  9. package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
  10. package/engine/commands/career-graph.cjs +1 -1
  11. package/engine/commands/graph.cjs +37 -2
  12. package/engine/commands/index.cjs +4 -1
  13. package/engine/commands/one.cjs +307 -0
  14. package/engine/commands/ontology.cjs +2 -2
  15. package/engine/commands/plugin.cjs +61 -16
  16. package/engine/commands/uninstall.cjs +43 -13
  17. package/engine/core/capability-grants.cjs +204 -0
  18. package/engine/core/desktop-core.cjs +34 -0
  19. package/engine/experience/build.cjs +8 -0
  20. package/engine/graph/ask-model.cjs +34 -2
  21. package/engine/graph/node-effect.cjs +56 -0
  22. package/engine/graph/package.cjs +6 -1
  23. package/engine/graph/vocabulary.generated.cjs +1 -1
  24. package/engine/hub/install.cjs +7 -3
  25. package/engine/hub/plugins.cjs +165 -0
  26. package/engine/mcp/consent.cjs +140 -12
  27. package/engine/mcp/index.cjs +1 -0
  28. package/engine/mcp/plan.cjs +63 -8
  29. package/engine/project/career-graph.cjs +5 -10
  30. package/engine/project/ontology.cjs +71 -29
  31. package/engine/sessions/memory-turn.cjs +39 -0
  32. package/engine/sessions/orchestrator.cjs +53 -4
  33. package/engine/sessions/session.cjs +10 -2
  34. package/engine/sessions/store.cjs +48 -12
  35. package/engine/ui/commands-catalog.cjs +1 -0
  36. package/engine/ui/repl.cjs +3 -1
  37. package/engine/vendor/desktop-core.manifest.json +5 -5
  38. package/package.json +4 -3
@@ -27,9 +27,13 @@ const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
27
27
  const { callHubTool } = require("../cloud/hub-client.cjs");
28
28
 
29
29
  // ── 패키지 상한/식별 상수 (서버 package-contract와 동일) ──
30
- const CLOUD_MAX_TOTAL_BYTES = 3 * 1024 * 1024;
31
- const CLOUD_MAX_FILE_BYTES = 512 * 1024;
32
- const CLOUD_MAX_FILES = 400;
30
+ // 상한은 정본 하나(upload-scan-catalog.json)에서 생성돼 내려온다. 여기서 다시
31
+ // 적으면 서버·엔진·데스크탑과 어긋나고, 어긋난 쪽은 파일 이름도 없는 코드로 거절한다.
32
+ const {
33
+ PACKAGE_MAX_TOTAL_BYTES: CLOUD_MAX_TOTAL_BYTES,
34
+ PACKAGE_MAX_FILE_BYTES: CLOUD_MAX_FILE_BYTES,
35
+ PACKAGE_MAX_FILES: CLOUD_MAX_FILES,
36
+ } = require("../cloud-assets/upload-scan-catalog.generated.cjs");
33
37
  const CLOUD_PACKAGE_HASH_V1 = "path-sha256-v1";
34
38
  const CLOUD_PACKAGE_HASH_V2 = "path-sha256-executable-v2";
35
39
  const CLOUD_RESTORE_MARKER_PATH = ".agentlas-cloud-package.json";
@@ -15,6 +15,9 @@
15
15
  * 런타임이 통째로 죽는다(Runtime Doctor가 반복해서 잡던 사고 계열).
16
16
  */
17
17
  const crypto = require("node:crypto");
18
+ const fs = require("node:fs");
19
+ const os = require("node:os");
20
+ const path = require("node:path");
18
21
  const { callHubTool, fetchHub, parseHubJson, webBaseUrl } = require("../cloud/hub-client.cjs");
19
22
 
20
23
  // 레포/홈페이지 HTML 페이지는 문서지 MCP 연결이 아니다. 이 URL들을 transport:"http"로
@@ -193,6 +196,163 @@ function installPluginMcpRows(db, rows) {
193
196
  return { installed, reused, needsApproval };
194
197
  }
195
198
 
199
+ // ── 스킬 번들 설치 (플러그인 = MCP와 별개의 능력 패키지, 오너 결정 2026-08-20) ──
200
+ //
201
+ // manifest.skills 행이 files[]에 실콘텐츠를 실으면 ~/.agentlas/plugins/<slug>/ 아래에
202
+ // 파일로 착지시키고 plugin.json 마커(schema agentlas.local-plugin/v1)를 남긴다.
203
+ // 이 규약은 데스크탑 electron/mcp-tools/hub-plugin-bridge.ts(installSkillBundle)와
204
+ // Agentlas-OS agentlas_cloud/plugin_discovery.py 스캔이 공유한다 — mcp_servers 등록이
205
+ // 아니라 파일시스템이 채널 간 공유 지점이다.
206
+
207
+ const PLUGIN_SKILL_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
208
+ const PLUGIN_SKILL_FILE_MAX_BYTES = 512 * 1024;
209
+
210
+ /** 세 채널이 공유하는 로컬 플러그인 저장소 루트. homeDir 주입은 테스트 격리용. */
211
+ function agentlasPluginsDir({ homeDir } = {}) {
212
+ return path.join(homeDir || os.homedir(), ".agentlas", "plugins");
213
+ }
214
+
215
+ /** 스킬 파일 상대 경로 검증 — 절대경로·상위 탈출·널바이트·백슬래시 거부 (데스크탑 동형). */
216
+ function pluginSkillSafeRelativePath(value) {
217
+ if (typeof value !== "string" || !value || value.length > 260) return false;
218
+ if (value.includes("\0") || value.includes("\\")) return false;
219
+ if (value.startsWith("/") || value.endsWith("/")) return false;
220
+ return value.split("/").every((part) => part.length > 0 && part !== "." && part !== ".." && !part.startsWith("~"));
221
+ }
222
+
223
+ /**
224
+ * manifest.skills 를 설치 계획으로 정규화: 실콘텐츠가 실린 스킬과 정직하게 거른 항목 분리.
225
+ * 이름뿐인 레거시 행({name}만)은 refused가 아니라 declaredOnly로 남긴다 — 결함이 아니라
226
+ * 과거 스키마의 정상 모양이다.
227
+ */
228
+ function planPluginSkillInstall(slug, manifest) {
229
+ const entries = Array.isArray(manifest?.skills) ? manifest.skills : [];
230
+ const skills = [];
231
+ const declaredOnly = [];
232
+ const refused = [];
233
+ for (const entry of entries) {
234
+ const name = typeof entry?.name === "string" ? entry.name.trim() : "";
235
+ if (!name) continue;
236
+ const rawFiles = Array.isArray(entry?.files) ? entry.files : [];
237
+ if (rawFiles.length === 0) {
238
+ declaredOnly.push(name);
239
+ continue;
240
+ }
241
+ if (!PLUGIN_SKILL_SLUG_RE.test(name)) {
242
+ refused.push({ name, reason: "invalid skill name" });
243
+ continue;
244
+ }
245
+ const files = [];
246
+ let bad = null;
247
+ for (const file of rawFiles) {
248
+ const filePath = typeof file?.path === "string" ? file.path.trim() : "";
249
+ const content = typeof file?.content === "string" ? file.content : "";
250
+ if (!pluginSkillSafeRelativePath(filePath)) { bad = `unsafe file path "${filePath}"`; break; }
251
+ if (!content.trim()) { bad = `empty content for ${filePath}`; break; }
252
+ if (Buffer.byteLength(content, "utf8") > PLUGIN_SKILL_FILE_MAX_BYTES) { bad = `${filePath} exceeds the file size cap`; break; }
253
+ const sha256 = typeof file?.sha256 === "string" && /^[0-9a-f]{64}$/i.test(file.sha256)
254
+ ? file.sha256.toLowerCase()
255
+ : null;
256
+ files.push({ path: filePath, content, sha256 });
257
+ }
258
+ if (bad) { refused.push({ name, reason: bad }); continue; }
259
+ skills.push({ name, description: typeof entry?.description === "string" ? entry.description : null, files });
260
+ }
261
+ return { skills, declaredOnly, refused };
262
+ }
263
+
264
+ /**
265
+ * 계획된 스킬들을 ~/.agentlas/plugins/<slug>/skills/<name>/ 에 쓴다.
266
+ *
267
+ * 무결성: 행이 sha256을 선언하면 쓰기 전에 검증하고, 불일치 스킬은 설치하지 않는다.
268
+ * 해시와 콘텐츠가 같은 매니페스트 응답으로 오므로 이 검증은 전송 무결성이지 발행자
269
+ * 서명이 아니다 — 마커의 source.manifestUrl이 출처 기록이다(정직한 한계).
270
+ */
271
+ function installPluginSkills(slug, plan, { homeDir, manifestUrl, meta } = {}) {
272
+ if (!PLUGIN_SKILL_SLUG_RE.test(String(slug || ""))) {
273
+ return { dir: "", installed: [], failed: [{ name: String(slug || ""), reason: "invalid plugin slug" }], verified: false };
274
+ }
275
+ const pluginDir = path.join(agentlasPluginsDir({ homeDir }), slug);
276
+ const installed = [];
277
+ const failed = [];
278
+ const markerSkills = [];
279
+ let allDeclared = true;
280
+ for (const skill of plan.skills || []) {
281
+ const written = [];
282
+ let mismatch = null;
283
+ for (const file of skill.files) {
284
+ const actual = crypto.createHash("sha256").update(file.content, "utf8").digest("hex");
285
+ if (file.sha256 && file.sha256 !== actual) { mismatch = `sha256 mismatch for ${file.path}`; break; }
286
+ if (!file.sha256) allDeclared = false;
287
+ written.push({ path: file.path, sha256: actual, verified: Boolean(file.sha256) });
288
+ }
289
+ if (mismatch) { failed.push({ name: skill.name, reason: mismatch }); continue; }
290
+ try {
291
+ const skillDir = path.join(pluginDir, "skills", skill.name);
292
+ for (const file of skill.files) {
293
+ const target = path.join(skillDir, file.path);
294
+ fs.mkdirSync(path.dirname(target), { recursive: true });
295
+ fs.writeFileSync(target, file.content, "utf8");
296
+ }
297
+ installed.push(skill.name);
298
+ markerSkills.push({ name: skill.name, files: written });
299
+ } catch (e) {
300
+ failed.push({ name: skill.name, reason: String((e && e.message) || e).slice(0, 160) });
301
+ }
302
+ }
303
+ const verified = installed.length > 0 && allDeclared;
304
+ if (installed.length > 0) {
305
+ // 마커는 마지막에 쓴다 — 마커가 있으면 스킬 파일도 있다는 뜻이어야 한다.
306
+ const marker = {
307
+ schema: "agentlas.local-plugin/v1",
308
+ slug,
309
+ name: (meta && meta.name) || slug,
310
+ family: (meta && meta.family) || null,
311
+ version: (meta && meta.version) || null,
312
+ installedAt: new Date().toISOString(),
313
+ installedBy: "agentlas-terminal",
314
+ source: { manifestUrl: manifestUrl || null, contentVerification: verified ? "manifest-sha256" : "none" },
315
+ skills: markerSkills,
316
+ };
317
+ try {
318
+ fs.mkdirSync(pluginDir, { recursive: true });
319
+ fs.writeFileSync(path.join(pluginDir, "plugin.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
320
+ } catch (e) {
321
+ failed.push({ name: "plugin.json", reason: String((e && e.message) || e).slice(0, 160) });
322
+ }
323
+ }
324
+ return { dir: pluginDir, installed, failed, verified };
325
+ }
326
+
327
+ /** ~/.agentlas/plugins/<slug>/plugin.json 마커들을 읽는다 — list의 설치 여부 표시용. */
328
+ function listInstalledLocalPlugins({ homeDir } = {}) {
329
+ const root = agentlasPluginsDir({ homeDir });
330
+ let names;
331
+ try {
332
+ names = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
333
+ } catch {
334
+ return [];
335
+ }
336
+ const out = [];
337
+ for (const name of names) {
338
+ if (name.startsWith(".")) continue;
339
+ try {
340
+ const marker = JSON.parse(fs.readFileSync(path.join(root, name, "plugin.json"), "utf8"));
341
+ out.push({
342
+ slug: String(marker.slug || name),
343
+ name: String(marker.name || name),
344
+ installedAt: marker.installedAt || null,
345
+ installedBy: marker.installedBy || null,
346
+ skills: Array.isArray(marker.skills) ? marker.skills.map((s) => String(s?.name || "")).filter(Boolean) : [],
347
+ dir: path.join(root, name),
348
+ });
349
+ } catch {
350
+ // 마커 없는 디렉터리는 다른 도구의 산출물일 수 있다 — 조용히 건너뛴다.
351
+ }
352
+ }
353
+ return out;
354
+ }
355
+
196
356
  /** Hub 플러그인 카탈로그 목록 (marketplace.list_plugins). 실패는 그대로 throw — 폴백 카탈로그 금지. */
197
357
  async function listHubPlugins({ callTool } = {}) {
198
358
  const call = callTool || callHubTool;
@@ -210,4 +370,9 @@ module.exports = {
210
370
  planPluginMcpInstall,
211
371
  installPluginMcpRows,
212
372
  listHubPlugins,
373
+ agentlasPluginsDir,
374
+ pluginSkillSafeRelativePath,
375
+ planPluginSkillInstall,
376
+ installPluginSkills,
377
+ listInstalledLocalPlugins,
213
378
  };
@@ -36,6 +36,82 @@ const {
36
36
  const MCP_CONSENT_STATE_SCHEMA = "agentlas.terminal-mcp-consents.v1";
37
37
  const MCP_CONSENT_RECEIPT_SCHEMA = "agentlas.terminal-mcp-consent.v1";
38
38
 
39
+ /*
40
+ * ── 통합 능력 승인(데스크탑 capability_grants)과의 합류 ───────────────────────
41
+ *
42
+ * 이 파일의 v1 계약(지문 일치 영수증)은 그대로다. 그 **위에** 공유 능력 규칙이 얹힌다:
43
+ * · 규칙이 deny 면 영수증이 있어도 붙이지 않는다(영구 거부는 어디서도 뚫리지 않는다).
44
+ * · 규칙이 allow 면 다시 묻지 않는다(데스크탑에서 누른 "항상 허용"이 여기서도 항상).
45
+ * · 규칙이 없으면 종전대로 1회 동의 프롬프트가 돈다.
46
+ * 터미널에서 "항상"을 고르면 같은 표에 써서 데스크탑에도 반영된다.
47
+ *
48
+ * MCP 서버를 붙이는 것은 외부 프로세스를 띄우는 일이라 능력 클래스는 execute 다.
49
+ * 규칙 키는 `tool:mcp:<catalogId>` + 패턴 없음(그 서버 전체) — 데스크탑의 도구 규칙과
50
+ * 같은 표·같은 판정 함수를 쓴다.
51
+ */
52
+ const MCP_CAPABILITY_CLASS = "execute";
53
+
54
+ function mcpCapabilityQuery(catalogId) {
55
+ return { capability: MCP_CAPABILITY_CLASS, tool: `mcp:${String(catalogId)}`, detail: String(catalogId) };
56
+ }
57
+
58
+ function capabilityGrantsModule() {
59
+ return require("../core/capability-grants.cjs");
60
+ }
61
+
62
+ /**
63
+ * 계획의 후보들을 공유 능력 규칙으로 미리 가른다.
64
+ * @returns {{available:string[], preApproved:string[], denied:string[],
65
+ * grantsAvailable:boolean, fallbackReason:string|null}}
66
+ * grantsAvailable=false 는 표가 없는 구버전 공유 DB — 사유를 담아 돌려주고 기존 동작으로 간다.
67
+ */
68
+ function partitionMcpConsentByCapabilityGrants(db, catalogIds) {
69
+ const ids = [...new Set((catalogIds || []).map((id) => String(id)).filter(Boolean))];
70
+ const grants = capabilityGrantsModule();
71
+ if (!db || !grants.capabilityGrantsAvailable(db)) {
72
+ return {
73
+ available: ids,
74
+ preApproved: [],
75
+ denied: [],
76
+ grantsAvailable: false,
77
+ fallbackReason: db ? grants.UNAVAILABLE_REASON : null,
78
+ };
79
+ }
80
+ const available = [];
81
+ const preApproved = [];
82
+ const denied = [];
83
+ let fallbackReason = null;
84
+ for (const id of ids) {
85
+ let ruling;
86
+ try {
87
+ ruling = grants.readCapabilityDecision(db, mcpCapabilityQuery(id));
88
+ } catch (error) {
89
+ fallbackReason = `capability_grants read failed: ${(error && error.message) || error}`;
90
+ available.push(id);
91
+ continue;
92
+ }
93
+ if (!ruling.available) {
94
+ fallbackReason = fallbackReason || ruling.reason;
95
+ available.push(id);
96
+ } else if (ruling.decision === "deny") denied.push(id);
97
+ else if (ruling.decision === "allow") preApproved.push(id);
98
+ else available.push(id);
99
+ }
100
+ return { available, preApproved, denied, grantsAvailable: true, fallbackReason };
101
+ }
102
+
103
+ /** 터미널에서 고른 "항상"을 데스크탑과 같은 표에 남긴다. 표가 없으면 정직한 실패. */
104
+ function rememberMcpCapabilityGrant(db, catalogId, decision = "allow") {
105
+ const grants = capabilityGrantsModule();
106
+ return grants.recordCapabilityGrant(db, {
107
+ capability: `tool:mcp:${String(catalogId)}`,
108
+ pattern: null,
109
+ decision: decision === "deny" ? "deny" : "allow",
110
+ scope: "global",
111
+ source: "terminal-mcp-consent",
112
+ });
113
+ }
114
+
39
115
  function mcpConsentStatePath(userDataDir) {
40
116
  return path.join(userDataDir, "terminal", "mcp-consents-v1.json");
41
117
  }
@@ -113,10 +189,16 @@ function readConsentedSystemMcpServers(db, options = {}) {
113
189
  let state;
114
190
  try { state = loadMcpConsentState(options.userDataDir); }
115
191
  catch { return []; }
192
+ // 영구 거부(deny)는 옛 동의 영수증을 이긴다 — 규칙이 영수증보다 위다.
193
+ const partition = partitionMcpConsentByCapabilityGrants(
194
+ db,
195
+ state.receipts.map((receipt) => receipt.catalogId),
196
+ );
197
+ const denied = new Set(partition.denied);
116
198
  const servers = [];
117
199
  const seen = new Set();
118
200
  for (const receipt of state.receipts) {
119
- if (seen.has(receipt.catalogId)) continue;
201
+ if (seen.has(receipt.catalogId) || denied.has(receipt.catalogId)) continue;
120
202
  let row = null;
121
203
  try {
122
204
  row = db.prepare(
@@ -134,26 +216,66 @@ function readConsentedSystemMcpServers(db, options = {}) {
134
216
  return servers;
135
217
  }
136
218
 
137
- function normalizeConsentAnswer(answer, availableIds) {
219
+ /**
220
+ * 답 한 줄을 해석한다. `always`/`a` 는 "전부 붙이고 **다시는 묻지 마라**" — 그 선택만
221
+ * 공유 능력 규칙(capability_grants)에 영구 기록된다. y/n/ids 는 종전 그대로 1회 한정이다.
222
+ */
223
+ function parseConsentAnswer(answer, availableIds) {
138
224
  const text = String(answer || "").trim();
139
- if (/^(?:y|yes|all|전체)$/i.test(text)) return [...availableIds];
140
- if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return [];
141
- const requested = parseIdList(text);
225
+ if (/^(?:a|always|항상)$/i.test(text)) return { ids: [...availableIds], always: true };
226
+ if (/^(?:y|yes|all|전체)$/i.test(text)) return { ids: [...availableIds], always: false };
227
+ if (!text || /^(?:n|no|none|없이|아니)$/i.test(text)) return { ids: [], always: false };
228
+ const requested = parseIdList(text.replace(/^always\s+/i, ""));
142
229
  const allowed = new Set(availableIds);
143
- return requested.filter((id) => allowed.has(id));
230
+ return { ids: requested.filter((id) => allowed.has(id)), always: /^always\s+/i.test(text) };
231
+ }
232
+
233
+ function normalizeConsentAnswer(answer, availableIds) {
234
+ return parseConsentAnswer(answer, availableIds).ids;
144
235
  }
145
236
 
146
237
  function askMcpConsentOnce(plan, options = {}) {
147
238
  const input = options.input || process.stdin;
148
239
  const output = options.output || process.stderr;
149
- // TTY가 아니면(파이프/자동화) 묻지 않고 빈 승인 — 조용한 전체 승인 금지.
150
- if (!input.isTTY || !output.isTTY || !plan.availableCatalogIds.length) return Promise.resolve([]);
240
+ /*
241
+ * 공유 능력 규칙을 먼저 본다(오너 결정 2026-08-20):
242
+ * deny → 후보에서 제외하고 묻지 않는다. allow → 묻지 않고 통과.
243
+ * 남은 후보만 사람에게 묻는다. 규칙이 없으면 목록이 그대로라 종전 동작과 동일하다.
244
+ */
245
+ const partition = partitionMcpConsentByCapabilityGrants(options.db, plan.availableCatalogIds || []);
246
+ const askable = partition.available;
247
+ const preApproved = partition.preApproved;
248
+ const notify = typeof options.onNotice === "function" ? options.onNotice : null;
249
+ if (notify) {
250
+ if (partition.denied.length) {
251
+ notify(`MCP blocked by a shared capability rule (Desktop/Terminal): ${partition.denied.join(", ")}`);
252
+ }
253
+ if (preApproved.length) {
254
+ notify(`MCP already allowed always (shared capability rule): ${preApproved.join(", ")}`);
255
+ }
256
+ if (partition.fallbackReason) notify(partition.fallbackReason);
257
+ }
258
+ // TTY가 아니면(파이프/자동화) 묻지 않는다 — 조용한 전체 승인 금지.
259
+ // 이미 "항상 허용"된 것만은 사람에게 물을 필요가 없으므로 그대로 통과시킨다.
260
+ if (!input.isTTY || !output.isTTY || !askable.length) return Promise.resolve([...preApproved]);
151
261
  const rl = readline.createInterface({ input, output, terminal: true });
152
262
  return new Promise((resolve) => {
153
- rl.question("Attach the available MCP recommendations? [y=all / n=none / comma-separated ids] ", (answer) => {
154
- rl.close();
155
- resolve(normalizeConsentAnswer(answer, plan.availableCatalogIds));
156
- });
263
+ rl.question(
264
+ "Attach the available MCP recommendations? [y=once / a=always / n=none / comma-separated ids] ",
265
+ (answer) => {
266
+ rl.close();
267
+ const parsed = parseConsentAnswer(answer, askable);
268
+ if (parsed.always && parsed.ids.length && options.db) {
269
+ for (const id of parsed.ids) {
270
+ const written = rememberMcpCapabilityGrant(options.db, id, "allow");
271
+ if (!written.ok && notify) notify(`"always" was not persisted for ${id}: ${written.reason}`);
272
+ }
273
+ } else if (parsed.always && parsed.ids.length && notify) {
274
+ notify("\"always\" was not persisted: no shared database handle was given to the consent prompt.");
275
+ }
276
+ resolve([...new Set([...preApproved, ...parsed.ids])]);
277
+ },
278
+ );
157
279
  });
158
280
  }
159
281
 
@@ -284,6 +406,12 @@ module.exports = {
284
406
  persistMcpConsentReceipts,
285
407
  readConsentedSystemMcpServers,
286
408
  normalizeConsentAnswer,
409
+ parseConsentAnswer,
287
410
  askMcpConsentOnce,
288
411
  resolveApprovedMcpRuntimeAllowlist,
412
+ // 공유 능력 승인(데스크탑 capability_grants) 합류 표면.
413
+ MCP_CAPABILITY_CLASS,
414
+ mcpCapabilityQuery,
415
+ partitionMcpConsentByCapabilityGrants,
416
+ rememberMcpCapabilityGrant,
289
417
  };
@@ -27,6 +27,7 @@ module.exports = {
27
27
  probeSystemMcpServerConnection: probe.probeSystemMcpServerConnection,
28
28
  // plan — 요구사항 해소 + 빌드 플랜 + 빌더 지시문
29
29
  resolveMcpRequirement: plan.resolveMcpRequirement,
30
+ inferRequirements: plan.inferRequirements,
30
31
  buildMcpPlan: plan.buildMcpPlan,
31
32
  renderMcpPlan: plan.renderMcpPlan,
32
33
  fitApprovedMcpIds: plan.fitApprovedMcpIds,
@@ -39,7 +39,10 @@ function syntheticRequirement(catalogId, required, priority) {
39
39
  };
40
40
  }
41
41
 
42
- // 이름 휴리스틱은 "추천 후보 회수" 담당한다최종 선택/attach 권한이 아니다.
42
+ // 이름 휴리스틱은 "회수 힌트"로만 강등되었다(2026-08-20)판정기에 참고로 전달될 뿐,
43
+ // 요구사항을 만들거나 선택 게이트로 동작하지 않는다. 데스크탑 need-resolver.ts가 폐기한
44
+ // 것과 같은 계약: 선택은 판정기(engine/agentlas-judgment.cjs) 경유, 판정 불가면
45
+ // 요구사항 없음(중립)이다.
43
46
  const HEURISTIC_GROUPS = [
44
47
  [/(browser|playwright|chrome|web)/i, /(?:browser|website|web page|웹|브라우저|사이트|페이지|로그인)/i],
45
48
  [/(github|gitlab|source)/i, /(?:github|gitlab|repository|pull request|issue|깃허브|레포|저장소)/i],
@@ -50,15 +53,56 @@ const HEURISTIC_GROUPS = [
50
53
  [/(search|research)/i, /(?:search|research|lookup|검색|리서치|조사)/i],
51
54
  ];
52
55
 
53
- function inferRequirements(request, inventory) {
54
- const text = String(request || "");
55
- const results = [];
56
+ /** 회수 힌트: 판정기 guidance에만 실린다. 선택·attach 권한이 없다. */
57
+ function lexicalRequirementHintIds(request, inventory) {
58
+ const text = String(request || "").toLowerCase();
59
+ const ids = [];
56
60
  for (const item of inventory) {
57
- const direct = text.toLowerCase().includes(item.catalogId.toLowerCase()) || text.toLowerCase().includes(item.name.toLowerCase());
61
+ const direct = text.includes(String(item.catalogId).toLowerCase()) || text.includes(String(item.name || "").toLowerCase());
58
62
  const heuristic = HEURISTIC_GROUPS.some(([nameRe, taskRe]) => nameRe.test(`${item.catalogId} ${item.name}`) && taskRe.test(text));
59
- if (direct || heuristic) results.push(syntheticRequirement(item.catalogId, false, results.length + 100));
63
+ if (direct || heuristic) ids.push(item.catalogId);
60
64
  }
61
- return results.slice(0, 8);
65
+ return ids.slice(0, 8);
66
+ }
67
+
68
+ /**
69
+ * 요청 텍스트에서 MCP 요구사항을 추론한다 — 판정기(연결된 모델) 경유.
70
+ * 정규식/이름 매칭은 힌트로만 전달되며, 판정이 없으면 빈 목록(중립)이다.
71
+ * 반환: syntheticRequirement[] (전부 optional/recommended 등급).
72
+ */
73
+ async function inferRequirements(request, inventory, options = {}) {
74
+ const text = String(request || "").trim();
75
+ const items = Array.isArray(inventory) ? inventory : [];
76
+ if (!text || !items.length) return [];
77
+ const judgment = options.judgment || require("../agentlas-judgment.cjs");
78
+ if (!judgment.hasJudgmentRunner()) return [];
79
+ const hintIds = lexicalRequirementHintIds(text, items);
80
+ const shelf = items
81
+ .map((item) => `- ${item.catalogId}: ${item.name || item.catalogId}`)
82
+ .join("\n");
83
+ const verdict = await judgment.judgeLabels({
84
+ kind: "terminal-mcp-need",
85
+ question:
86
+ "Which of the available MCP tools does this build request genuinely require in order to complete? Judge what the task actually does, not which words it contains.",
87
+ labels: items.map((item) => String(item.catalogId)),
88
+ input: `TASK:\n${text.slice(0, 4000)}\n\nAVAILABLE TOOLS:\n${shelf}`,
89
+ guidance: [
90
+ "Name a tool ONLY when the task cannot be completed without it.",
91
+ "Mentioning a topic is not a need: a task that says 'research'/'조사' in passing does not need a web-search tool.",
92
+ hintIds.length
93
+ ? `A deterministic name-heuristic suggested [${hintIds.join(", ")}] — treat that as a hint, never a gate.`
94
+ : "",
95
+ "An empty list is a valid and often correct answer. Err toward fewer tools.",
96
+ ].filter(Boolean).join(" "),
97
+ signal: options.signal,
98
+ timeoutMs: options.timeoutMs,
99
+ });
100
+ if (verdict.source !== "llm") return []; // 판정 불가 → 요구사항 없음(중립)
101
+ const known = new Set(items.map((item) => String(item.catalogId)));
102
+ return verdict.labels
103
+ .filter((catalogId) => known.has(catalogId))
104
+ .slice(0, 8)
105
+ .map((catalogId, index) => syntheticRequirement(catalogId, false, index + 100));
62
106
  }
63
107
 
64
108
  function indexInventory(inventory) {
@@ -119,7 +163,17 @@ function buildMcpPlan(options) {
119
163
  assertId(catalogId, "--recommend-mcp");
120
164
  if (!known.has(catalogId)) { requirements.push(syntheticRequirement(catalogId, false, 500)); known.add(catalogId); }
121
165
  }
122
- if (!requirements.length) requirements.push(...inferRequirements(options.request, inventory));
166
+ // 2026-08-20: 휴리스틱 자동 추론이 여기서 직접 요구사항을 만들던 게이트를 제거.
167
+ // 추론은 비동기 판정(inferRequirements — 판정기 경유, 판정 불가면 빈 목록)을
168
+ // 호출자가 먼저 끝내고 그 결과를 넘긴다. 없으면 요구사항 없음(중립).
169
+ if (!requirements.length && Array.isArray(options.inferredRequirements)) {
170
+ for (const requirement of options.inferredRequirements.slice(0, 8)) {
171
+ if (requirement && requirement.catalogId && !known.has(requirement.catalogId)) {
172
+ requirements.push(requirement);
173
+ known.add(requirement.catalogId);
174
+ }
175
+ }
176
+ }
123
177
  const entries = requirements
124
178
  .map((requirement) => {
125
179
  const resolution = resolveMcpRequirement(requirement, inventoryById);
@@ -275,6 +329,7 @@ function renderBuildMcpResult(plan, approvedIds, runtimeAllowlist = null) {
275
329
  module.exports = {
276
330
  MAX_BUILD_DIRECTIVE_CHARS,
277
331
  syntheticRequirement,
332
+ lexicalRequirementHintIds,
278
333
  inferRequirements,
279
334
  indexInventory,
280
335
  resolveMcpRequirement,
@@ -173,7 +173,7 @@ function registerCareerGraphSourceCli(paths, source, kind, scope, cwd, lang) {
173
173
  ];
174
174
  }
175
175
 
176
- function runCareerGraphCli(args, opts) {
176
+ async function runCareerGraphCli(args, opts) {
177
177
  opts = opts || {};
178
178
  const ko = opts.lang === "ko";
179
179
  const cwd = path.resolve(opts.cwd || process.cwd());
@@ -203,10 +203,8 @@ function runCareerGraphCli(args, opts) {
203
203
  }
204
204
  const directCareerCommand = ["open", "add"].includes(String(sub));
205
205
  if (!directCareerCommand) {
206
- const parsed = parseOntologyNaturalArgsCli(normalizedArgs.join(" "), cwd);
207
- if (!parsed) throw new Error(ko
208
- ? "사용법: /career-graph status|list|open|add <경로>"
209
- : "usage: /career-graph status|list|open|add <path>");
206
+ // 자연어는 판정기 경유로 액션을 정한다. 판정 불가면 ["help"](사용법 안내).
207
+ const parsed = await parseOntologyNaturalArgsCli(normalizedArgs.join(" "), cwd);
210
208
  return runCareerGraphCli(parsed, opts);
211
209
  }
212
210
  const paths = ensureCareerGraphCli(projectPath, opts.lang);
@@ -226,12 +224,9 @@ function runCareerGraphCli(args, opts) {
226
224
  : "usage: /career-graph status|list|open|add <path>");
227
225
  }
228
226
 
229
- function runCareerGraphNaturalCli(text, opts) {
227
+ async function runCareerGraphNaturalCli(text, opts) {
230
228
  const cwd = path.resolve((opts && opts.cwd) || process.cwd());
231
- const parsed = parseOntologyNaturalArgsCli(text, cwd);
232
- if (!parsed) throw new Error((opts && opts.lang) === "ko"
233
- ? "사용법: /career-graph status|list|open|add <경로>"
234
- : "usage: /career-graph status|list|open|add <path>");
229
+ const parsed = await parseOntologyNaturalArgsCli(text, cwd);
235
230
  return runCareerGraphCli(parsed, { ...(opts || {}), cwd });
236
231
  }
237
232