agentlas 1.0.60 → 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 (37) 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 +6 -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 +22 -0
  19. package/engine/experience/build.cjs +8 -0
  20. package/engine/graph/node-effect.cjs +56 -0
  21. package/engine/graph/package.cjs +6 -1
  22. package/engine/graph/vocabulary.generated.cjs +1 -1
  23. package/engine/hub/install.cjs +7 -3
  24. package/engine/hub/plugins.cjs +165 -0
  25. package/engine/mcp/consent.cjs +140 -12
  26. package/engine/mcp/index.cjs +1 -0
  27. package/engine/mcp/plan.cjs +63 -8
  28. package/engine/project/career-graph.cjs +5 -10
  29. package/engine/project/ontology.cjs +71 -29
  30. package/engine/sessions/memory-turn.cjs +39 -0
  31. package/engine/sessions/orchestrator.cjs +53 -4
  32. package/engine/sessions/session.cjs +10 -2
  33. package/engine/sessions/store.cjs +48 -12
  34. package/engine/ui/commands-catalog.cjs +1 -0
  35. package/engine/ui/repl.cjs +3 -1
  36. package/engine/vendor/desktop-core.manifest.json +5 -5
  37. package/package.json +4 -3
@@ -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
 
@@ -258,9 +258,7 @@ function isOntologyPathishCli(token, cwd, allowExistingName) {
258
258
  return false;
259
259
  }
260
260
 
261
- function findOntologyPathTokenCli(tokens, cwd, text) {
262
- const lower = String(text || "").toLowerCase();
263
- const addIntent = /(add|register|attach|source|folder|watch|sync|추가|등록|붙|연결|폴더|자료|문서)/i.test(lower);
261
+ function findOntologyPathTokenCli(tokens, cwd, addIntent) {
264
262
  const skip = new Set([
265
263
  "add", "register", "attach", "source", "sources", "folder", "folders", "watch", "sync", "use",
266
264
  "company", "personal", "project", "private", "internal", "public", "work", "business",
@@ -274,25 +272,74 @@ function findOntologyPathTokenCli(tokens, cwd, text) {
274
272
  return null;
275
273
  }
276
274
 
277
- function parseOntologyNaturalArgsCli(text, cwd) {
275
+ /*
276
+ * 자연어 → 온톨로지 CLI 액션 (2026-08-20: 전면 판정기 경유로 교체).
277
+ * 예전에는 액션·kind·scope 전부 ko/en 정규식이 확정했다 — 제3언어는 영구 미도달,
278
+ * 우연한 단어 일치("register a canon decision")는 오폭. 이제:
279
+ * - 액션(status/open/add/help)은 판정기(engine/agentlas-judgment.cjs)가 뜻으로 고른다.
280
+ * - add의 kind/scope도 같은 판정기 경유(불가 시 안전 기본값 project/private).
281
+ * - 판정 불가면 ["help"] — 단어장 폴백 없음.
282
+ * 경로 토큰 추출(findOntologyPathTokenCli)은 fs 실존 검사 기반의 구조 증거라 유지한다.
283
+ */
284
+ const ONTOLOGY_NATURAL_ACTIONS = ["status", "open", "add", "help"];
285
+ const ONTOLOGY_ADD_FACETS = ["company", "personal", "project", "public", "internal", "private", "current-directory"];
286
+
287
+ async function judgeOntologyNaturalCli(raw, options = {}) {
288
+ let judgment;
289
+ try {
290
+ judgment = options.judgment || require("../agentlas-judgment.cjs");
291
+ } catch {
292
+ return { action: null, kind: null, scope: null };
293
+ }
294
+ if (!judgment.hasJudgmentRunner()) return { action: null, kind: null, scope: null };
295
+ const actionVerdict = await judgment.judgeLabels({
296
+ kind: "terminal-ontology-natural-action",
297
+ question:
298
+ "Which single ontology CLI action does this natural-language request ask for? status = show the current ontology state or list registered sources; open = open the ontology inbox folder; add = register a folder, file, or document collection as an ontology source; help = explain usage.",
299
+ labels: ONTOLOGY_NATURAL_ACTIONS,
300
+ input: raw,
301
+ multi: false,
302
+ guidance:
303
+ "Judge meaning in any language. Naming a concrete folder/path/material to attach or watch means add. Enabling/starting the ontology means status. When the request is not an ontology action at all, choose help.",
304
+ signal: options.signal,
305
+ timeoutMs: options.timeoutMs,
306
+ });
307
+ if (actionVerdict.source !== "llm" || actionVerdict.labels.length !== 1) {
308
+ return { action: null, kind: null, scope: null };
309
+ }
310
+ const action = actionVerdict.labels[0];
311
+ if (action !== "add") return { action, kind: null, scope: null };
312
+ const facetVerdict = await judgment.judgeLabels({
313
+ kind: "terminal-ontology-add-facets",
314
+ question:
315
+ "For this source-registration request, which facets apply? Material kind: company (work/organization material), personal (private-life material), project (this project's material). Sharing scope: public, internal (team/company shared), private (only this user). Location: current-directory when the request refers to the folder the user is currently in ('this folder', 'here').",
316
+ labels: ONTOLOGY_ADD_FACETS,
317
+ input: raw,
318
+ guidance:
319
+ "Judge meaning in any language. Pick at most one kind and at most one scope; pick nothing for a facet the request does not state. Pick current-directory only for an explicit reference to the present folder, not for a named path.",
320
+ signal: options.signal,
321
+ timeoutMs: options.timeoutMs,
322
+ });
323
+ const facets = facetVerdict.source === "llm" ? facetVerdict.labels : [];
324
+ const kind = ["company", "personal", "project"].find((label) => facets.includes(label)) || null;
325
+ const scope = ["public", "internal", "private"].find((label) => facets.includes(label)) || null;
326
+ return { action, kind, scope, currentDirectory: facets.includes("current-directory") };
327
+ }
328
+
329
+ async function parseOntologyNaturalArgsCli(text, cwd, options = {}) {
278
330
  const raw = String(text || "").trim();
279
331
  if (!raw) return ["status"];
280
- const lower = raw.toLowerCase();
281
- if (/^(?:help|\?|도움|사용법)\b/i.test(raw)) return ["help"];
282
- if (/(?:^|\s)(?:list|ls|sources?|status|show|상태|목록|리스트)(?:\s|$)/i.test(raw)) return ["list"];
283
- if (/(?:^|\s)(?:open|inbox|finder|열어|열기|인박스)(?:\s|$)/i.test(raw)) return ["open"];
332
+ const judged = await judgeOntologyNaturalCli(raw, options);
333
+ if (judged.action === null || judged.action === "help") return ["help"];
334
+ if (judged.action === "status") return ["list"];
335
+ if (judged.action === "open") return ["open"];
284
336
  const tokens = shellSplitCli(raw);
285
- const kind = inferOntologyKindCli(null, raw);
286
- const scope = inferOntologyScopeCli(null, raw, kind);
287
- let source = findOntologyPathTokenCli(tokens, cwd, raw);
288
- if (!source && /(?:this folder|current folder|here|이\s*폴더|현재\s*폴더|지금\s*폴더|여기)/i.test(raw)) source = ".";
289
- const wantsAdd = Boolean(source) || /(add|register|attach|source|watch|sync|추가|등록|붙|연결)/i.test(lower);
290
- if (wantsAdd) {
291
- if (!source) return ["add"];
292
- return ["add", source, "--kind", kind, "--scope", scope];
293
- }
294
- if (/(enable|activate|start|turn on|켜|시작|활성)/i.test(lower)) return ["status"];
295
- return null;
337
+ let source = findOntologyPathTokenCli(tokens, cwd, true);
338
+ if (!source && judged.currentDirectory) source = ".";
339
+ if (!source) return ["add"];
340
+ const kind = judged.kind || "project";
341
+ const scope = judged.scope || "private";
342
+ return ["add", source, "--kind", kind, "--scope", scope];
296
343
  }
297
344
 
298
345
  function formatOntologyStatusCli(paths, lang) {
@@ -374,7 +421,7 @@ function openLocalPathCli(targetPath, notify) {
374
421
  }
375
422
  }
376
423
 
377
- function runOntologyCli(args, opts) {
424
+ async function runOntologyCli(args, opts) {
378
425
  opts = opts || {};
379
426
  const ko = opts.lang === "ko";
380
427
  const cwd = path.resolve(opts.cwd || process.cwd());
@@ -397,10 +444,8 @@ function runOntologyCli(args, opts) {
397
444
  const directOntologyCommand = ["open", "add", "company", "personal", "project"].includes(String(sub).toLowerCase())
398
445
  || isOntologyPathishCli(sub, cwd, true);
399
446
  if (!directOntologyCommand) {
400
- const parsed = parseOntologyNaturalArgsCli(normalizedArgs.join(" "), cwd);
401
- if (!parsed) throw new Error(ko
402
- ? "사용법: /ontology status|list|open|add <경로>"
403
- : "usage: /ontology status|list|open|add <path>");
447
+ // 자연어는 판정기 경유로 액션을 정한다. 판정 불가면 ["help"](사용법 안내).
448
+ const parsed = await parseOntologyNaturalArgsCli(normalizedArgs.join(" "), cwd);
404
449
  return runOntologyCli(parsed, opts);
405
450
  }
406
451
  const paths = ensureOntologyCli(projectPath, opts.lang);
@@ -429,12 +474,9 @@ function runOntologyCli(args, opts) {
429
474
  : "usage: /ontology status|list|open|add <path>");
430
475
  }
431
476
 
432
- function runOntologyNaturalCli(text, opts) {
477
+ async function runOntologyNaturalCli(text, opts) {
433
478
  const cwd = path.resolve((opts && opts.cwd) || process.cwd());
434
- const parsed = parseOntologyNaturalArgsCli(text, cwd);
435
- if (!parsed) throw new Error((opts && opts.lang) === "ko"
436
- ? "사용법: /ontology status|list|open|add <경로>"
437
- : "usage: /ontology status|list|open|add <path>");
479
+ const parsed = await parseOntologyNaturalArgsCli(text, cwd);
438
480
  return runOntologyCli(parsed, { ...(opts || {}), cwd });
439
481
  }
440
482