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.
- package/engine/agentlas-memory-governance.cjs +88 -10
- package/engine/agentlas-permissions.cjs +95 -1
- package/engine/agentlas-tools.cjs +45 -2
- package/engine/agents/builder.cjs +4 -0
- package/engine/architecture.data.json +1 -1
- package/engine/bootstrap-schema.sql +188 -24
- package/engine/cloud-assets/cas.cjs +27 -1
- package/engine/cloud-assets/package.cjs +126 -0
- package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
- package/engine/commands/career-graph.cjs +1 -1
- package/engine/commands/graph.cjs +37 -2
- package/engine/commands/index.cjs +4 -1
- package/engine/commands/one.cjs +307 -0
- package/engine/commands/ontology.cjs +2 -2
- package/engine/commands/plugin.cjs +61 -16
- package/engine/commands/uninstall.cjs +43 -13
- package/engine/core/capability-grants.cjs +204 -0
- package/engine/core/desktop-core.cjs +34 -0
- package/engine/experience/build.cjs +8 -0
- package/engine/graph/ask-model.cjs +34 -2
- package/engine/graph/node-effect.cjs +56 -0
- package/engine/graph/package.cjs +6 -1
- package/engine/graph/vocabulary.generated.cjs +1 -1
- package/engine/hub/install.cjs +7 -3
- package/engine/hub/plugins.cjs +165 -0
- package/engine/mcp/consent.cjs +140 -12
- package/engine/mcp/index.cjs +1 -0
- package/engine/mcp/plan.cjs +63 -8
- package/engine/project/career-graph.cjs +5 -10
- package/engine/project/ontology.cjs +71 -29
- package/engine/sessions/memory-turn.cjs +39 -0
- package/engine/sessions/orchestrator.cjs +53 -4
- package/engine/sessions/session.cjs +10 -2
- package/engine/sessions/store.cjs +48 -12
- package/engine/ui/commands-catalog.cjs +1 -0
- package/engine/ui/repl.cjs +3 -1
- package/engine/vendor/desktop-core.manifest.json +5 -5
- package/package.json +4 -3
package/engine/hub/install.cjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
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";
|
package/engine/hub/plugins.cjs
CHANGED
|
@@ -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
|
};
|
package/engine/mcp/consent.cjs
CHANGED
|
@@ -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
|
-
|
|
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 (/^(?:
|
|
140
|
-
if (
|
|
141
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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(
|
|
154
|
-
|
|
155
|
-
|
|
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
|
};
|
package/engine/mcp/index.cjs
CHANGED
|
@@ -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,
|
package/engine/mcp/plan.cjs
CHANGED
|
@@ -39,7 +39,10 @@ function syntheticRequirement(catalogId, required, priority) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// 이름 휴리스틱은 "
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
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.
|
|
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)
|
|
63
|
+
if (direct || heuristic) ids.push(item.catalogId);
|
|
60
64
|
}
|
|
61
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|