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
@@ -11,7 +11,11 @@ const {
11
11
  planPluginMcpInstall,
12
12
  installPluginMcpRows,
13
13
  listHubPlugins,
14
+ planPluginSkillInstall,
15
+ installPluginSkills,
16
+ listInstalledLocalPlugins,
14
17
  } = require("../hub/plugins.cjs");
18
+ const { webBaseUrl } = require("../cloud/hub-client.cjs");
15
19
  const { terminalProjectCandidateCli, initializedAgentlasProjectPathCli } = require("../project/state.cjs");
16
20
 
17
21
  async function pluginAdd(ctx, slug) {
@@ -31,43 +35,80 @@ async function pluginAdd(ctx, slug) {
31
35
  return 1;
32
36
  }
33
37
  const { rows, refused } = planPluginMcpInstall(slug, manifest);
38
+ // 스킬 번들 절반 — 실콘텐츠(files[])가 실린 스킬은 ~/.agentlas/plugins/<slug>/ 에
39
+ // 파일로 착지한다(오너 결정 2026-08-20: 플러그인 = MCP와 별개의 능력 패키지).
40
+ const skillPlan = planPluginSkillInstall(slug, manifest);
34
41
  const docsLink = manifest.docs || manifest.source?.repo || manifest.source?.homepage || null;
35
- if (!rows.length) {
36
- const reasonLines = refused.map((item) => ` ✗ ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`);
42
+ if (!rows.length && !skillPlan.skills.length) {
43
+ const reasonLines = [
44
+ ...refused.map((item) => ` ✗ ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`),
45
+ ...skillPlan.refused.map((item) => ` ✗ skill ${item.name}: ${item.reason}`),
46
+ ...(skillPlan.declaredOnly.length
47
+ ? [` ✗ skills declared without content payloads: ${skillPlan.declaredOnly.join(", ")}`]
48
+ : []),
49
+ ];
37
50
  ctx.err(
38
51
  [
39
- `${slug} ships no machine-connectable MCP endpoint yet. Nothing was registered.`,
52
+ `${slug} ships no machine-connectable MCP endpoint and no installable skill payload. Nothing was installed.`,
40
53
  ...reasonLines,
41
54
  docsLink ? ` docs: ${docsLink} (upstream project page — not an MCP endpoint)` : null,
42
- " When the catalog gains verified connection info for this plugin, re-run: agentlas plugin add " + slug,
55
+ " When the catalog gains verified connection info or skill payloads for this plugin, re-run: agentlas plugin add " + slug,
43
56
  ].filter(Boolean).join("\n"),
44
57
  );
45
58
  return 1;
46
59
  }
47
- let installed, reused, needsApproval;
48
- try {
49
- ({ installed, reused, needsApproval } = installPluginMcpRows(ctx.db(), rows));
50
- } catch (e) {
51
- ctx.err(String((e && e.message) || e));
52
- return 1;
60
+ let installed = 0, reused = 0, needsApproval = [];
61
+ if (rows.length) {
62
+ try {
63
+ ({ installed, reused, needsApproval } = installPluginMcpRows(ctx.db(), rows));
64
+ } catch (e) {
65
+ ctx.err(String((e && e.message) || e));
66
+ return 1;
67
+ }
68
+ }
69
+ let skillResult = null;
70
+ if (skillPlan.skills.length) {
71
+ skillResult = installPluginSkills(slug, skillPlan, {
72
+ manifestUrl: `${webBaseUrl()}/api/plugins/${encodeURIComponent(slug)}`,
73
+ meta: { name: manifest.name, family: manifest.family, version: manifest.version },
74
+ });
75
+ if (!skillResult.installed.length && !rows.length) {
76
+ // 스킬만 실린 매니페스트인데 하나도 못 썼다 — 조용한 성공 금지.
77
+ for (const item of skillResult.failed) ctx.err(` ✗ skill ${item.name}: ${item.reason}`);
78
+ ctx.err(`${slug}: no skill could be installed.`);
79
+ return 1;
80
+ }
53
81
  }
54
82
  ctx.out(`${ctx.ui.green("✓")} Plugin installed ${ctx.ui.accent(manifest.slug)} — ${manifest.name}`);
55
83
  for (const item of refused) {
56
84
  ctx.out(` ⚠ skipped ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`);
57
85
  }
58
- ctx.out(` MCP servers: ${installed} added${reused ? `, ${reused} already present` : ""}`);
59
- // 데스크탑 hub-plugin-bridge.ts:219-227 동형: stdio는 비활성 등록 + 승인 필요 표면화.
86
+ if (rows.length) {
87
+ ctx.out(` MCP servers: ${installed} added${reused ? `, ${reused} already present` : ""}`);
88
+ }
89
+ // 데스크탑 hub-plugin-bridge.ts 동형: stdio는 비활성 등록 + 승인 필요 표면화.
60
90
  for (const name of needsApproval || []) {
61
91
  ctx.out(` ⚠ needs-approval ${name}: local execution requires one-click approval in MCP settings`);
62
92
  }
93
+ if (skillResult) {
94
+ ctx.out(` skills installed: ${skillResult.installed.join(", ")} → ${skillResult.dir}`);
95
+ if (!skillResult.verified) {
96
+ ctx.out(ctx.ui.dim(" (no content hash declared — manifest URL recorded as provenance)"));
97
+ }
98
+ for (const item of skillResult.failed) {
99
+ ctx.out(` ⚠ skipped skill ${item.name}: ${item.reason}`);
100
+ }
101
+ }
102
+ if (skillPlan.declaredOnly.length) {
103
+ ctx.out(` skills declared (no payload yet): ${skillPlan.declaredOnly.join(", ")}`);
104
+ }
63
105
  const authKind = manifest.auth?.kind;
64
106
  if (authKind && authKind !== "none") {
65
107
  ctx.out(` ⚠ Requires ${authKind} — set credentials before use (agentlas creds).`);
66
108
  }
67
- if (Array.isArray(manifest.skills) && manifest.skills.length) {
68
- ctx.out(` skills declared: ${manifest.skills.map((skill) => skill.name).filter(Boolean).join(", ")}`);
109
+ if (rows.length) {
110
+ ctx.out(ctx.ui.dim(" Only full-access turns wire active stdio servers into the runtime (agentlas mcp)."));
69
111
  }
70
- ctx.out(ctx.ui.dim(" Only full-access turns wire active stdio servers into the runtime (agentlas mcp)."));
71
112
  return 0;
72
113
  }
73
114
 
@@ -83,8 +124,12 @@ async function pluginList(ctx) {
83
124
  ctx.out("No Hub plugins are available.");
84
125
  return 0;
85
126
  }
127
+ // 설치 여부는 ~/.agentlas/plugins/<slug>/plugin.json 마커(3채널 공유 규약)로 판정한다.
128
+ const installedSlugs = new Set(listInstalledLocalPlugins().map((entry) => entry.slug.toLowerCase()));
86
129
  for (const plugin of plugins.slice(0, 60)) {
87
- ctx.out(`${ctx.ui.accent(String(plugin.slug || "").padEnd(32).slice(0, 32))} ${String(plugin.name || "").slice(0, 44)}`);
130
+ const slugText = String(plugin.slug || "");
131
+ const installedMark = installedSlugs.has(slugText.toLowerCase()) ? ctx.ui.green(" [installed]") : "";
132
+ ctx.out(`${ctx.ui.accent(slugText.padEnd(32).slice(0, 32))} ${String(plugin.name || "").slice(0, 44)}${installedMark}`);
88
133
  }
89
134
  ctx.out("");
90
135
  ctx.out(ctx.ui.dim("Install: agentlas plugin add <slug>"));
@@ -9,6 +9,26 @@ const { findAgent } = require("../agents/registry.cjs");
9
9
  const { routesMap, saveRoutes } = require("../agents/routes.cjs");
10
10
  const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
11
11
 
12
+ /**
13
+ * 이 DB 에서 에이전트를 지우면 그 대화도 함께 사라지는가.
14
+ *
15
+ * `chats.agent_id` 의 삭제 동작이 답이다. 좌석-세션 이전 스키마는 `CASCADE`(대화도 삭제),
16
+ * 이후는 `SET NULL`(자리만 비고 대화는 보존). 이 CLI 는 데스크탑과 같은 파일을 쓰고 그
17
+ * 파일의 사다리 위치는 기기마다 다르므로, 스키마 번호나 코드가 쓰인 시점이 아니라
18
+ * **열려 있는 파일**에 물어야 한다.
19
+ */
20
+ function chatDeletionCascadesFromAgent(db) {
21
+ try {
22
+ const rows = db.prepare("SELECT \"table\" AS parent, \"from\" AS child, \"on_delete\" AS onDelete FROM pragma_foreign_key_list('chats')").all();
23
+ const link = rows.find((row) => row.child === "agent_id" && row.parent === "installed_agents");
24
+ // 관계가 없으면 지워도 대화가 따라가지 않는다 — 파괴를 예고하지 않는다.
25
+ return String(link && link.onDelete || "").toUpperCase() === "CASCADE";
26
+ } catch {
27
+ // 물어볼 수 없으면 보수적으로 파괴한다고 본다 — 동의를 한 번 더 묻는 쪽이 안전하다.
28
+ return true;
29
+ }
30
+ }
31
+
12
32
  function run(ctx, args) {
13
33
  const ko = ctx.lang === "ko";
14
34
  // 동의 플래그를 슬러그보다 앞에 써도 되도록 첫 번째 비플래그 인자를 대상으로 본다.
@@ -47,17 +67,24 @@ function run(ctx, args) {
47
67
  }
48
68
 
49
69
  /*
50
- * 대화 파괴 게이트 — bootstrap-schema.sql:50 chats.agent_id
51
- * `ON DELETE CASCADE`, :61 chat_messages.chat_id 도 CASCADE 다. 즉
52
- * installed_agents 하나를 지우면 에이전트의 모든 챗과 메시지가 같이
53
- * 사라진다(데스크탑과 공유하는 SQLite라 데스크탑 이력도 함께 증발한다).
54
- * 그런데 명령은 사실을 줄도 알리지 않고 `✓ 제거됨`만 찍었다
55
- * 되돌릴 수 없는 삭제가 무고지·무확인으로 일어나던 자리다.
56
- * 그래서 지우기 "전에" 파급 건수를 세고, 대화가 있으면 명시적 동의
57
- * (--yes/-y/--force) 없이는 아무것도 지우지 않는다. 대화가 0건이면
58
- * 파괴할 이력이 없으므로 종전대로 그냥 진행한다(불필요한 마찰 금지).
70
+ * 대화 파괴 게이트 — **파괴가 실제로 일어나는 스키마에서만.**
71
+ *
72
+ * 예전에는 `chats.agent_id` `ON DELETE CASCADE` 였다. 그래서 에이전트 한 행을 지우면
73
+ * 대화와 메시지가 함께 사라졌고, 명령은 그 사실을 한 줄도 알리지 않고 `✓ 제거됨`만
74
+ * 찍었다 되돌릴 없는 삭제가 무고지로 일어나던 자리였고, 그래서 게이트가 생겼다.
75
+ *
76
+ * 좌석-세션 이후 제약은 `ON DELETE SET NULL` 내려갔다. 에이전트를 지우는 것은
77
+ * 이제 **자리를 비우는 일**이고 대화는 그대로 남는다(오너 정본: 삭제 = 자리 비우기).
78
+ * 그런데 명령은 계속 "영구 삭제합니다" 라고 경고하고 "삭제됨" 이라고 보고했다 —
79
+ * 지켜지지 않는 약속의 반대, 즉 **일어나지 않은 파괴를 보고하는 거짓말**이다. 사용자는
80
+ * 남아 있는 이력을 잃었다고 믿고, 잃지 않아도 될 동의를 강요받는다.
81
+ *
82
+ * 그래서 문구를 새 스키마에 맞춰 바꿔 쓰지 않는다. 이 CLI 는 데스크탑과 **같은 파일**을
83
+ * 쓰고 그 파일의 스키마는 기기마다 다르다 — 아직 옛 사다리에 있는 기기에서는 파괴가
84
+ * 여전히 사실이다. 판단은 열려 있는 DB 의 외래키에서 읽는다.
59
85
  */
60
86
  const consented = args.some((a) => a === "--yes" || a === "-y" || a === "--force");
87
+ const cascades = ctx.tableExists(db, "chats") && chatDeletionCascadesFromAgent(db);
61
88
  let chatCount = 0;
62
89
  let messageCount = 0;
63
90
  if (ctx.tableExists(db, "chats")) {
@@ -68,7 +95,7 @@ function run(ctx, args) {
68
95
  ).get(agent.id).n;
69
96
  }
70
97
  }
71
- if (chatCount && !consented) {
98
+ if (chatCount && cascades && !consented) {
72
99
  ctx.err(ko
73
100
  ? `"${agent.slug}" 제거는 대화 ${chatCount}개와 메시지 ${messageCount}개를 함께 영구 삭제합니다 (데스크탑 앱과 공유하는 DB이며 되돌릴 수 없습니다).\n계속하려면: agentlas uninstall ${agent.slug} --yes`
74
101
  : `Uninstalling "${agent.slug}" will also permanently delete ${chatCount} chat(s) and ${messageCount} message(s) (shared with the Desktop app; this cannot be undone).\nRe-run to confirm: agentlas uninstall ${agent.slug} --yes`);
@@ -89,9 +116,12 @@ function run(ctx, args) {
89
116
  }
90
117
  } catch { /* 라우트 정리는 best-effort */ }
91
118
  // 실제로 무엇이 사라졌는지 성공 줄에 남긴다 — 조용한 파괴 금지.
92
- const cascade = chatCount
93
- ? (ko ? ` (대화 ${chatCount}개 · 메시지 ${messageCount}개 삭제됨)` : ` (deleted ${chatCount} chat(s), ${messageCount} message(s))`)
94
- : "";
119
+ // 실제로 무엇이 일어났는지만 적는다 — 지웠으면 지웠다고, 남겼으면 남겼다고.
120
+ const cascade = !chatCount
121
+ ? ""
122
+ : cascades
123
+ ? (ko ? ` (대화 ${chatCount}개 · 메시지 ${messageCount}개 삭제됨)` : ` (deleted ${chatCount} chat(s), ${messageCount} message(s))`)
124
+ : (ko ? ` (대화 ${chatCount}개 · 메시지 ${messageCount}개는 그대로 남습니다 — 자리만 비었습니다)` : ` (kept ${chatCount} chat(s) and ${messageCount} message(s) — the seat is now empty)`);
95
125
  ctx.out(`${ctx.ui.green("✓")} ${ko ? "제거됨" : "Uninstalled"}: ${agent.slug}${cascade}`);
96
126
  return 0;
97
127
  }
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ /*
3
+ * core/capability-grants — 데스크탑이 소유한 통합 능력 승인(capability_grants)을
4
+ * 터미널이 **읽고 쓴다**.
5
+ *
6
+ * 오너 결정(2026-08-20): 승인은 에이전트별·채널별이 아니라 **행동 기준**이고 공유된다.
7
+ * 데스크탑에서 "항상 허용"을 누른 행동은 터미널에서도 다시 묻지 않아야 하고, 데스크탑에서
8
+ * 영구 거부된 행동은 터미널의 어떤 권한 등급으로도 뚫려서는 안 된다. 표는 하나(공유
9
+ * agentlas.sqlite, v98)이므로 규칙도 하나다 — 터미널이 자기 사본을 들면 그 순간 갈라진다.
10
+ *
11
+ * 정본 구현: agentlas_desktop/electron/store/capability-grants.ts.
12
+ * 이 파일은 그 **판정 규칙을 그대로** 옮긴 것이다(키 후보 3종, 스코프 구체성 내림차순,
13
+ * 같은 스코프 안에서 deny > allow, 프리픽스 패턴). 규칙이 갈리면 같은 머신의 두 제품이
14
+ * 같은 행동에 다른 답을 준다 — 바꿀 때는 반드시 양쪽을 함께 바꿀 것.
15
+ *
16
+ * ★터미널은 마이그레이션 follower 다(core/db.cjs 참조). 표가 없으면 **만들지 않는다** —
17
+ * 조용히 기존 동작(터미널 자체 동의/권한 규칙)으로 폴백하고, 왜 폴백했는지 사유를
18
+ * 호출부에 돌려준다. 부재를 성공으로 위장하지 않는다.
19
+ */
20
+ const { tableExists } = require("./db.cjs");
21
+
22
+ const TABLE = "capability_grants";
23
+
24
+ /** 표가 없는(구버전) 공유 DB 에서 호출부가 사용자에게 그대로 보여줄 수 있는 사유. */
25
+ const UNAVAILABLE_REASON =
26
+ "shared database has no capability_grants table (older schema) — " +
27
+ "Terminal fell back to its own consent/permission rules. " +
28
+ "Launch the Agentlas Desktop app once to migrate the shared store.";
29
+
30
+ /**
31
+ * 능력 클래스 — "항상 허용"이 영구 부여하는 단위.
32
+ * 정본: desktop electron/runtime/tool-approval.ts capabilityClassFor().
33
+ */
34
+ function capabilityClassFor(kind, tool) {
35
+ if (kind === "execute" || tool === "bash") return "execute";
36
+ if (kind === "delete") return "delete";
37
+ if (kind === "edit") return "edit";
38
+ if (kind === "fetch" || kind === "network") return "network";
39
+ return "other";
40
+ }
41
+
42
+ /**
43
+ * "항상 허용"이 저장할 인자 패턴 — Claude Code 프리픽스 규칙과 같은 일반화.
44
+ * 정본: desktop tool-approval.ts generalizeDetailPattern().
45
+ */
46
+ function generalizeDetailPattern(detail) {
47
+ if (!detail) return null;
48
+ const tokens = String(detail).trim().split(/\s+/);
49
+ if (tokens.length <= 2) return String(detail).trim();
50
+ return `${tokens[0]} ${tokens[1]} *`;
51
+ }
52
+
53
+ /** "git push *" 스타일 프리픽스 패턴. NULL/빈 패턴은 인자 무관 매치. */
54
+ function patternMatches(pattern, detail) {
55
+ if (pattern === null || pattern === undefined || pattern === "") return true;
56
+ if (!detail) return false;
57
+ const text = String(pattern);
58
+ if (text.endsWith("*")) return String(detail).startsWith(text.slice(0, -1).trimEnd());
59
+ return String(detail) === text;
60
+ }
61
+
62
+ /** 구체성 내림차순 — 먼저 맞은 스코프가 이긴다(chat > agent > global). */
63
+ function scopesFor(query) {
64
+ const scopes = [];
65
+ if (query && query.chatId) scopes.push(`chat:${query.chatId}`);
66
+ if (query && query.agentId) scopes.push(`agent:${query.agentId}`);
67
+ scopes.push("global");
68
+ return scopes;
69
+ }
70
+
71
+ /** 표가 실제로 있는가. 만들지 않는다 — 확인만 한다. */
72
+ function capabilityGrantsAvailable(db) {
73
+ if (!db) return false;
74
+ return tableExists(db, TABLE);
75
+ }
76
+
77
+ /**
78
+ * 저장된 규칙으로 결정을 찾는다.
79
+ * @returns {{decision: "allow"|"deny"|null, available: boolean, reason: string|null}}
80
+ * decision === null 은 "규칙 없음" — 호출부가 기존 동작(질문/권한 등급)으로 간다.
81
+ */
82
+ function readCapabilityDecision(db, query) {
83
+ if (!capabilityGrantsAvailable(db)) {
84
+ return { decision: null, available: false, reason: UNAVAILABLE_REASON };
85
+ }
86
+ const capability = String((query && query.capability) || "other");
87
+ const keys = [capability];
88
+ if (query && query.tool) keys.push(`tool:${query.tool}`);
89
+ keys.push("*");
90
+ let rows;
91
+ try {
92
+ rows = db
93
+ .prepare(
94
+ `SELECT capability, pattern, decision, scope FROM ${TABLE} ` +
95
+ `WHERE capability IN (${keys.map(() => "?").join(",")})`,
96
+ )
97
+ .all(...keys);
98
+ } catch (error) {
99
+ // 표는 있는데 열 모양이 우리가 아는 것과 다르다 — 추측하지 않고 폴백한다.
100
+ return {
101
+ decision: null,
102
+ available: false,
103
+ reason: `capability_grants is present but unreadable (${(error && error.message) || error}) — Terminal fell back to its own rules.`,
104
+ };
105
+ }
106
+ if (!rows || rows.length === 0) return { decision: null, available: true, reason: null };
107
+ for (const scope of scopesFor(query)) {
108
+ const inScope = rows.filter(
109
+ (row) => row.scope === scope && patternMatches(row.pattern, query && query.detail),
110
+ );
111
+ if (inScope.length === 0) continue;
112
+ if (inScope.some((row) => row.decision === "deny")) return { decision: "deny", available: true, reason: null };
113
+ return { decision: "allow", available: true, reason: null };
114
+ }
115
+ return { decision: null, available: true, reason: null };
116
+ }
117
+
118
+ /**
119
+ * 규칙을 영속한다(같은 (capability, pattern, scope)는 마지막 결정으로 덮는다).
120
+ * 데스크탑의 recordCapabilityGrant 와 **같은 행**을 쓴다 — 터미널에서 고른
121
+ * "항상 허용"이 데스크탑에도 그대로 보인다.
122
+ *
123
+ * 표가 없으면 만들지 않고 정직하게 실패를 알린다(ok:false + reason).
124
+ */
125
+ function recordCapabilityGrant(db, input) {
126
+ if (!capabilityGrantsAvailable(db)) {
127
+ return { ok: false, available: false, reason: UNAVAILABLE_REASON };
128
+ }
129
+ const decision = input && input.decision === "deny" ? "deny" : "allow";
130
+ try {
131
+ db.prepare(
132
+ `INSERT INTO ${TABLE} (capability, pattern, decision, scope, source, created_at)
133
+ VALUES (?, ?, ?, ?, ?, ?)
134
+ ON CONFLICT(capability, pattern, scope)
135
+ DO UPDATE SET decision = excluded.decision, source = excluded.source, created_at = excluded.created_at`,
136
+ ).run(
137
+ String((input && input.capability) || "other"),
138
+ input && input.pattern != null && input.pattern !== "" ? String(input.pattern) : null,
139
+ decision,
140
+ String((input && input.scope) || "global"),
141
+ String((input && input.source) || "terminal"),
142
+ new Date().toISOString(),
143
+ );
144
+ return { ok: true, available: true, reason: null };
145
+ } catch (error) {
146
+ return {
147
+ ok: false,
148
+ available: true,
149
+ reason: `capability_grants write failed: ${(error && error.message) || error}`,
150
+ };
151
+ }
152
+ }
153
+
154
+ /** 조회용(도구·게이트가 사람에게 보여줄 때). 표가 없으면 빈 배열 + available:false. */
155
+ function listCapabilityGrants(db, scope) {
156
+ if (!capabilityGrantsAvailable(db)) return { rows: [], available: false, reason: UNAVAILABLE_REASON };
157
+ try {
158
+ const rows = scope
159
+ ? db.prepare(`SELECT * FROM ${TABLE} WHERE scope = ? ORDER BY id`).all(String(scope))
160
+ : db.prepare(`SELECT * FROM ${TABLE} ORDER BY id`).all();
161
+ return {
162
+ rows: (rows || []).map((row) => ({
163
+ id: Number(row.id),
164
+ capability: String(row.capability),
165
+ pattern: row.pattern == null ? null : String(row.pattern),
166
+ decision: row.decision === "deny" ? "deny" : "allow",
167
+ scope: String(row.scope),
168
+ source: String(row.source == null ? "chip" : row.source),
169
+ createdAt: String(row.created_at == null ? "" : row.created_at),
170
+ })),
171
+ available: true,
172
+ reason: null,
173
+ };
174
+ } catch (error) {
175
+ return { rows: [], available: false, reason: `capability_grants read failed: ${(error && error.message) || error}` };
176
+ }
177
+ }
178
+
179
+ /** 대화 전체 통과("항상 승인" 대화) — 데스크탑 isChatAlwaysApproved 와 같은 행. */
180
+ function isChatAlwaysApproved(db, chatId) {
181
+ if (!capabilityGrantsAvailable(db) || !chatId) return false;
182
+ try {
183
+ const row = db
184
+ .prepare(`SELECT decision FROM ${TABLE} WHERE capability = '*' AND scope = ? ORDER BY id DESC LIMIT 1`)
185
+ .get(`chat:${chatId}`);
186
+ return !!row && row.decision === "allow";
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ module.exports = {
193
+ TABLE,
194
+ UNAVAILABLE_REASON,
195
+ capabilityClassFor,
196
+ generalizeDetailPattern,
197
+ patternMatches,
198
+ scopesFor,
199
+ capabilityGrantsAvailable,
200
+ readCapabilityDecision,
201
+ recordCapabilityGrant,
202
+ listCapabilityGrants,
203
+ isChatAlwaysApproved,
204
+ };
@@ -231,12 +231,46 @@ function loadDesktopCore(options = {}) {
231
231
  initStore: req("store/db").initStore,
232
232
  getDb: req("store/db").getDb,
233
233
  runGraph: kernel.runGraph,
234
+ /*
235
+ * 저장 전 확인 — 빌더가 만든 스크립트를 **저장하기 전에** 돌려 보고, 안 되면 한 번
236
+ * 고친다. 옛 코어에는 없으므로 정직하게 null 로 둔다(부르는 쪽이 없으면 건너뛴다).
237
+ */
238
+ verifyBeforeSave: (() => {
239
+ try {
240
+ const mod = req("workflow/verify-before-save");
241
+ return typeof mod.verifyGraphBeforeSaveWithKernel === "function"
242
+ ? { run: mod.verifyGraphBeforeSaveWithKernel, render: mod.renderPreSaveVerification }
243
+ : null;
244
+ } catch { return null; }
245
+ })(),
234
246
  getAutomation: req("store/automations").getAutomation,
235
247
  // shared/ 는 electron/ 밖이라 req 로 못 닿는다 — 직접 해석한다.
236
248
  graphExecutionDigest: (() => {
237
249
  try { return require(path.join(root, "shared", "graph-execution-digest.js")).graphExecutionDigest; }
238
250
  catch { return null; }
239
251
  })(),
252
+ /*
253
+ * ★"이 노드가 바깥을 바꾸나" 는 판정이 **한 곳**에서만 나와야 한다. 벤더에는 이미
254
+ * shared/graph-node-protocol.js 가 실려 있었는데 이 표면에 없어서, 터미널은
255
+ * 부를 수가 없었고 그래서 자기 사본을 들고 있었다(engine/graph/package.cjs,
256
+ * engine/commands/graph.cjs). 사본은 게으름이 아니라 **정본에 못 닿아서** 생긴다.
257
+ * 옛 코어를 쓰는 동안에는 없을 수 있으므로 정직하게 null 로 둔다 — 부르는 쪽이
258
+ * 없으면 자기 판단을 쓰되, 그 사실이 보이게.
259
+ */
260
+ nodeEffectJudgments: (() => {
261
+ try {
262
+ const mod = require(path.join(root, "shared", "graph-node-protocol.js"));
263
+ return typeof mod.nodeCouldHaveActedOutside === "function" ? {
264
+ resolveNodeEffect: mod.resolveNodeEffect,
265
+ nodeDeclaresOutwardEffect: mod.nodeDeclaresOutwardEffect,
266
+ nodeCouldHaveActedOutside: mod.nodeCouldHaveActedOutside,
267
+ } : null;
268
+ } catch { return null; }
269
+ })(),
270
+ couldHaveChangedTheOutsideWorld: (() => {
271
+ try { return require(path.join(root, "shared", "tool-activity.js")).couldHaveChangedTheOutsideWorld; }
272
+ catch { return null; }
273
+ })(),
240
274
  graphFailureOf: kernel.graphFailureOf,
241
275
  planGraphLoops: kernel.planGraphLoops,
242
276
  /*
@@ -114,9 +114,17 @@ async function cmdBuild(options) {
114
114
  const emit = options.out || console.log;
115
115
  const inventory = mcp.collectSystemMcpInventory(options.db, { userDataDir: options.userDataDir, env: options.env || process.env });
116
116
  const policy = mcp.loadProjectMcpPolicy(options.cwd || process.cwd());
117
+ // 명시적 요구(정책/플래그)가 하나도 없을 때만 추론 — 판정기(연결 모델) 경유이며,
118
+ // 판정 불가면 빈 목록(중립)이다. 휴리스틱 정규식은 판정 힌트로만 실린다.
119
+ const explicitRequirementCount =
120
+ ((policy && policy.requirements) || []).length + parsed.requiredIds.length + parsed.recommendedIds.length;
121
+ const inferredRequirements = explicitRequirementCount === 0
122
+ ? await mcp.inferRequirements(parsed.request, inventory)
123
+ : [];
117
124
  const plan = mcp.buildMcpPlan({
118
125
  inventory, policy, request: parsed.request,
119
126
  requiredIds: parsed.requiredIds, recommendedIds: parsed.recommendedIds,
127
+ inferredRequirements,
120
128
  });
121
129
  emit(parsed.json ? JSON.stringify(plan, null, 2) : mcp.renderMcpPlan(plan));
122
130
  if (parsed.planOnly) return { plan, approvedIds: [], invoked: false };
@@ -50,6 +50,23 @@ function quietSink() {
50
50
  * @returns {Promise<{ok:true,text:string,runtime:string,fellBackFrom?:string}
51
51
  * |{ok:false,reason:string,nextAction:string}>}
52
52
  */
53
+ /**
54
+ * 이 컴퓨터에서 **이미 동의된** MCP 서버들. 빌더도 실행기와 같은 재료를 본다.
55
+ *
56
+ * 새 동의를 받지 않는다 — 만드는 중에 승인 창을 띄우면 사람이 흐름에서 튕긴다.
57
+ * 못 읽으면 빈 배열: 예전 동작과 같아질 뿐 나빠지지 않는다(조용한 실패가 아니라
58
+ * "없는 것"이 사실이다).
59
+ */
60
+ function consentedMcpServersFor(ctx) {
61
+ try {
62
+ const mcp = require("../mcp/index.cjs");
63
+ if (typeof mcp.readConsentedSystemMcpServers !== "function") return [];
64
+ return mcp.readConsentedSystemMcpServers(ctx.db(), { env: process.env }) || [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
53
70
  async function askModel(ctx, prompt, opts = {}) {
54
71
  const db = ctx.db();
55
72
  let primary = null;
@@ -103,12 +120,27 @@ async function askModel(ctx, prompt, opts = {}) {
103
120
  ui: quietSink(),
104
121
  cwd: opts.cwd || process.cwd(),
105
122
  prompt,
106
- // 읽기 권한 — 인터뷰는 사람에게 묻고 형식을 만드는 일이라 파일을 바꿀 이유가 없다.
123
+ /*
124
+ * 읽기 권한 — 만드는 동안 바깥을 바꾸지 않는다. 메일이 나가거나 글이 올라가면 안 된다.
125
+ * ★런타임의 "read"는 **쓰기 금지가 아니라 도구 금지에 가깝다**(이 저장소 실측:
126
+ * 조회 그래프가 조회조차 못 했던 사고). 조회 도구는 남으므로 확인은 할 수 있다.
127
+ */
107
128
  permission: "read",
108
129
  session: {},
109
130
  model: runtime.model,
110
131
  effort: runtime.effort,
111
- mcpServers: [],
132
+ /*
133
+ * ★사용자가 **이미 동의한** MCP 를 빌더에게도 준다.
134
+ *
135
+ * 실측 2026-08-20: 여기가 빈 배열이었다. 그래서 빌더는 이 컴퓨터에 무엇이
136
+ * 연결돼 있는지 모른 채 그래프를 지었고, 자기가 쓴 스크립트가 도는지도 볼 수
137
+ * 없었다. 도구는 제품에 다 있는데(브라우저·MCP·크리덴셜) **만드는 자리에만
138
+ * 안 닿아 있었다.**
139
+ *
140
+ * 새로 동의를 받지 않는다 — 이미 받아 둔 것만 그대로 쓴다(consent 영수증 기준).
141
+ * 못 읽으면 빈 배열로 간다: 예전과 같아질 뿐 나빠지지 않는다.
142
+ */
143
+ mcpServers: consentedMcpServersFor(ctx),
112
144
  mcpAllowlistMode: "exact",
113
145
  });
114
146
  } catch (err) {
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ /*
3
+ * "이 노드가 바깥을 바꾸나" — 터미널 쪽 **거울 하나**.
4
+ *
5
+ * 정본은 데스크탑의 `shared/graph-node-protocol.ts` 다. 터미널이 그걸 직접 부르지
6
+ * 못하는 이유는 하나뿐이다: 그 판정은 `.filter()` 안에서 **동기로** 필요한데, 엔진을
7
+ * 얻는 길(`acquireCore`)은 비동기다(새 설치는 아직 내려받지 않았을 수 있다).
8
+ * 동기 로더로 우회하면 새로 설치한 사람에게만 조용히 다른 답이 나온다 —
9
+ * `verify-engine-reachable` 게이트가 정확히 그걸 막는다.
10
+ *
11
+ * 그래서 규칙을 여기 한 번 편다. 대신 **같은 답을 내는지 게이트가 증명한다**
12
+ * (`scripts/verify-node-effect-parity.cjs`). 거울이 허용되는 조건은 그 증명뿐이다.
13
+ *
14
+ * 왜 이 판정이 중요한가 (실측 2026-08-20):
15
+ * `config.effect === "mutation"` 만 보면 emitter 가 만든 출력 노드(effect 칸이
16
+ * 아예 없음)가 "바깥에 안 나감"으로 읽힌다. 그 노드의 기본값은 나가는 것이다.
17
+ * 데스크탑에서 같은 구멍이 다섯 곳에 있었다 — 도구 모드·패키지 경고·권한 유도·
18
+ * 발행 심사·패치 승인. 터미널도 세 곳에 있었다.
19
+ */
20
+
21
+ /** 안 적힌 효과의 기본값. 출력 블록은 "바깥으로 내보내기"다(레지스트리 선언). */
22
+ function defaultNodeEffect(nodeType) {
23
+ return nodeType === "output" ? "mutation" : "read";
24
+ }
25
+
26
+ /** 이 노드의 효과. 선언된 것이 있으면 그것을 믿고, 없으면 종류의 기본값이다. */
27
+ function resolveNodeEffect(node) {
28
+ const declared = typeof node?.config?.effect === "string" ? node.config.effect.trim() : "";
29
+ if (declared === "mutation" || declared === "read" || declared === "pure") return declared;
30
+ return defaultNodeEffect(String(node?.type ?? ""));
31
+ }
32
+
33
+ /**
34
+ * ① 바깥으로 나간다고 **선언돼 있는가** — 사람에게 "이 단계는 발행한다"고 말할 근거.
35
+ * 패키지 경고·발행 고지가 쓴다.
36
+ */
37
+ function nodeDeclaresOutwardEffect(node) {
38
+ return resolveNodeEffect(node) === "mutation";
39
+ }
40
+
41
+ /**
42
+ * ② 바깥에 뭔가 **했을 수 있는가** — 재개가 묻는 다른 질문. ①의 상위집합이다.
43
+ * 모델을 부르는 단계는 선언이 read 여도 도구를 부를 수 있다.
44
+ * (정본이 이 둘을 갈라 놓은 이유는 shared/graph-node-protocol.ts 주석에 있다.)
45
+ */
46
+ function nodeCouldHaveActedOutside(node) {
47
+ if (nodeDeclaresOutwardEffect(node)) return true;
48
+ return node?.type === "agent" || node?.type === "action" || node?.type === "output";
49
+ }
50
+
51
+ module.exports = {
52
+ defaultNodeEffect,
53
+ resolveNodeEffect,
54
+ nodeDeclaresOutwardEffect,
55
+ nodeCouldHaveActedOutside,
56
+ };
@@ -1,4 +1,6 @@
1
1
  "use strict";
2
+
3
+ const { nodeDeclaresOutwardEffect: reachesOutside } = require("./node-effect.cjs");
2
4
  /*
3
5
  * .agentgraph 패키징 — 그래프를 남에게 줄 수 있는 형태로 만든다.
4
6
  *
@@ -31,6 +33,8 @@ const SECRET_KEY_RE = /(token|secret|password|passwd|apikey|api_key|credential|p
31
33
  /** 로컬 사용자 경로 — 남의 기계에서 의미가 없고, 계정명이 그대로 드러난다. */
32
34
  const PERSONAL_PATH_RE = /(\/Users\/[^/\s"']+|\/home\/[^/\s"']+|C:\\Users\\[^\\\s"']+)/g;
33
35
 
36
+
37
+
34
38
  function vaultKeyFor(nodeId, key) {
35
39
  return `${String(key).replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
36
40
  }
@@ -113,6 +117,7 @@ function buildPackage(input) {
113
117
  // 에이전트 참조는 핀으로 남긴다 — 받는 사람이 무엇을 빌려야 하는지 알아야 한다.
114
118
  // 노드가 ref를 선언하지 않으면 자동화의 대상 에이전트를 상속한다(제품의 실제 동작).
115
119
  // 그 경우를 빼면 패키지가 "채울 것 없음"이라고 거짓말한다.
120
+ // judgment-exempt: 이건 "바깥을 바꾸나"가 아니라 "이 단계가 에이전트를 굴리나"다.
116
121
  const isAgentish = node.type === "agent" || node.type === "action" || node.type === "output";
117
122
  const ref = typeof node.config?.ref === "string" && node.config.ref ? node.config.ref : null;
118
123
  const inheritedSlug = automation.target_id || null;
@@ -139,7 +144,7 @@ function buildPackage(input) {
139
144
  }
140
145
 
141
146
  const mutationNodes = nodes
142
- .filter((n) => n.config?.effect === "mutation")
147
+ .filter((n) => reachesOutside(n))
143
148
  .map((n) => ({ nodeId: n.id, label: n.label || n.id }));
144
149
 
145
150
  const scrubbedGraph = { version: graph.version ?? 1, nodes, edges: graph.edges || [] };
@@ -7,7 +7,7 @@
7
7
  "use strict";
8
8
 
9
9
  const GRAPH_WIRE = "graph/1";
10
- const GRAPH_ERROR_CODES = ["APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_PRODUCED_NOTHING","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_FAILED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_CLAIMED_WITHOUT_TOOLS","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
10
+ const GRAPH_ERROR_CODES = ["APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_PRODUCED_NOTHING","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_CONTRADICTS_BRANCH","EVAL_FAILED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_CLAIMED_WITHOUT_TOOLS","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
11
11
  const GRAPH_JOURNAL_KINDS = ["blob_externalized","node_failed","node_intent","node_reserved","node_retry","node_routed","node_settled","resumed","run_completed","run_created","run_failed","run_validated","suspended"];
12
12
  const GRAPH_NODE_KINDS = ["action","agent","code","condition","eval","output","subgraph","tool","transform","trigger"];
13
13
  const GRAPH_BLOCK_UI = {"trigger":{"section":"none","placeable":false,"placeReason":"그래프마다 하나뿐이고 처음 만들 때 함께 지어진다"},"agent":{"section":"inventory","placeable":true},"eval":{"section":"flow","placeable":true},"condition":{"section":"flow","placeable":true},"transform":{"section":"flow","placeable":true},"code":{"section":"flow","placeable":true},"tool":{"section":"inventory","placeable":true},"action":{"section":"actions","placeable":true},"output":{"section":"flow","placeable":true},"loop":{"section":"none","placeable":false,"placeReason":"노드가 아니라 되돌아가는 연결의 성질이다 — 엣지를 이어서 만든다"},"subgraph":{"section":"flow","placeable":true}};