agentlas 1.0.60 → 1.0.62

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 (56) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/engine/acp/server.cjs +47 -17
  3. package/engine/agentlas-evolution.cjs +85 -36
  4. package/engine/agentlas-experience-intake.cjs +21 -8
  5. package/engine/agentlas-memory-governance.cjs +88 -10
  6. package/engine/agentlas-permissions.cjs +95 -1
  7. package/engine/agentlas-tools.cjs +45 -2
  8. package/engine/agentlas.cjs +6 -3
  9. package/engine/agents/builder.cjs +4 -0
  10. package/engine/architecture.data.json +1 -1
  11. package/engine/automation/daemon.cjs +36 -3
  12. package/engine/automation/store.cjs +13 -11
  13. package/engine/bootstrap-schema.sql +216 -24
  14. package/engine/cloud/auth.cjs +34 -8
  15. package/engine/cloud-assets/cas.cjs +27 -1
  16. package/engine/cloud-assets/package.cjs +126 -0
  17. package/engine/cloud-assets/upload-scan-catalog.generated.cjs +13 -0
  18. package/engine/commands/career-graph.cjs +1 -1
  19. package/engine/commands/connect.cjs +34 -6
  20. package/engine/commands/graph.cjs +6 -2
  21. package/engine/commands/index.cjs +4 -1
  22. package/engine/commands/oberon.cjs +1 -1
  23. package/engine/commands/one.cjs +307 -0
  24. package/engine/commands/ontology.cjs +2 -2
  25. package/engine/commands/plugin.cjs +61 -16
  26. package/engine/commands/uninstall.cjs +43 -13
  27. package/engine/commands/update.cjs +25 -4
  28. package/engine/core/capability-grants.cjs +204 -0
  29. package/engine/core/desktop-core-fetch.cjs +94 -21
  30. package/engine/core/desktop-core.cjs +22 -0
  31. package/engine/experience/build.cjs +8 -0
  32. package/engine/graph/node-effect.cjs +56 -0
  33. package/engine/graph/package.cjs +6 -1
  34. package/engine/graph/vocabulary.generated.cjs +1 -1
  35. package/engine/hephaestus/local-core.cjs +44 -9
  36. package/engine/hub/install.cjs +7 -3
  37. package/engine/hub/plugins.cjs +165 -0
  38. package/engine/mcp/consent.cjs +140 -12
  39. package/engine/mcp/index.cjs +1 -0
  40. package/engine/mcp/plan.cjs +63 -8
  41. package/engine/memory-cli/curate.cjs +5 -2
  42. package/engine/oberon/outputs.cjs +10 -3
  43. package/engine/project/career-graph.cjs +9 -12
  44. package/engine/project/memory-context.cjs +9 -6
  45. package/engine/project/ontology.cjs +88 -36
  46. package/engine/runtimes/acp-driver.cjs +42 -3
  47. package/engine/sessions/memory-turn.cjs +39 -0
  48. package/engine/sessions/orchestrator.cjs +53 -4
  49. package/engine/sessions/session.cjs +28 -2
  50. package/engine/sessions/store.cjs +48 -12
  51. package/engine/telegram/connect.cjs +31 -10
  52. package/engine/ui/commands-catalog.cjs +1 -0
  53. package/engine/ui/repl.cjs +3 -1
  54. package/engine/ui/screens.cjs +30 -6
  55. package/engine/vendor/desktop-core.manifest.json +5 -5
  56. package/package.json +6 -3
package/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.0.62 — 2026-08-29
4
+
5
+ - Terminal sessions now preserve ACP conversation identity and history across
6
+ editor turns, propagate cancellation and consented MCP configuration to
7
+ Cursor/Grok/Kimi, and reject overlapping prompts instead of cross-streaming
8
+ two JSON-RPC requests.
9
+ - Automation leases, Telegram pairing, cloud session storage, Local Core
10
+ transport, evolution writes, experience events, project path matching, and
11
+ memory ownership now fail closed under races and partial failures.
12
+ - The on-demand Desktop core cache is bound to the exact release SHA, includes
13
+ every built-in plugin runtime package, and is published as the audited
14
+ `desktop-core-v15` asset without dependency tests, fixtures, or local paths.
15
+ - npm packages exclude the generated Desktop core tree and tarball, shrinking
16
+ the install artifact from roughly 36 MB to under 1 MB while retaining the
17
+ checksummed v15 fetch manifest. The publication workflow enforces this
18
+ boundary.
19
+ - `agentlas update` now bounds registry connection and body parsing so a stalled
20
+ npm request cannot hang the CLI indefinitely.
4
21
 
5
22
  - `agentlas acp` — Agentlas as an Agent Client Protocol agent (Phase B-3). Zed,
6
23
  JetBrains IDEs and other ACP clients can run the project controller (or an
@@ -50,8 +50,20 @@ function textOfPrompt(blocks) {
50
50
  * events: { onDelta(text), onTool(name, summary, id), onToolResult(text, ok, id), onStatus(text) }
51
51
  * cancel(sessionKey) → void
52
52
  */
53
- function productionTurnRunner() {
54
- const sessions = new Map(); // acp sessionId → { session, agent, runtime }
53
+ function productionTurnRunner(options = {}) {
54
+ const sessions = new Map(); // currently running acp sessionId → Session
55
+ const createSession = options.createSession || ((ctx, spec, prompt) => {
56
+ const { Orchestrator } = require("../sessions/orchestrator.cjs");
57
+ const orch = new Orchestrator({ db: ctx.db(), lang: ctx.lang });
58
+ const session = orch.spawn({
59
+ agent: spec.agent,
60
+ runtime: spec.runtime,
61
+ permission: spec.permission,
62
+ cwd: spec.cwd,
63
+ title: prompt.slice(0, 60),
64
+ });
65
+ return { orch, session };
66
+ });
55
67
  return {
56
68
  async newSession(ctx, { cwd, runtimeKind, agentSlug }) {
57
69
  const { projectCwd } = require("../project/paths.cjs");
@@ -81,9 +93,17 @@ function productionTurnRunner() {
81
93
  return { agent, runtime, permission, cwd: workdir, project: resolved ? resolved.project : null };
82
94
  },
83
95
  async runTurn(ctx, spec, prompt, events) {
84
- const { Orchestrator } = require("../sessions/orchestrator.cjs");
85
- const orch = new Orchestrator({ db: ctx.db(), lang: ctx.lang });
86
- const session = orch.spawn({ agent: spec.agent, runtime: spec.runtime, permission: spec.permission, cwd: spec.cwd, title: prompt.slice(0, 60) });
96
+ // An ACP session is conversational. Recreating Terminal's Session here on
97
+ // every prompt gave each editor turn a new chat/runtime fingerprint even
98
+ // though the client kept sending the same ACP sessionId. Keep one Session
99
+ // on the session spec so DB history and provider resume state survive.
100
+ if (!spec.session) {
101
+ const created = await createSession(ctx, spec, prompt);
102
+ spec.orch = created && created.orch || null;
103
+ spec.session = created && created.session || created;
104
+ }
105
+ const session = spec.session;
106
+ if (!session || typeof session.send !== "function") throw new Error("ACP session runner failed to create a Terminal session");
87
107
  sessions.set(spec.acpSessionId, session);
88
108
  const listener = (ev) => {
89
109
  try {
@@ -104,7 +124,7 @@ function productionTurnRunner() {
104
124
  };
105
125
  } finally {
106
126
  session.removeListener("event", listener);
107
- sessions.delete(spec.acpSessionId);
127
+ if (sessions.get(spec.acpSessionId) === session) sessions.delete(spec.acpSessionId);
108
128
  }
109
129
  },
110
130
  cancel(acpSessionId) {
@@ -207,9 +227,15 @@ class AcpAgentServer {
207
227
  case "session/prompt": {
208
228
  const spec = this.sessions.get(String(params.sessionId));
209
229
  if (!spec) return this.error(id, -32602, "unknown sessionId");
230
+ // readline dispatches messages concurrently. Session.send() queues a
231
+ // second prompt and returns the first turn's promise, which would make
232
+ // both JSON-RPC requests stream each other's events and settle with the
233
+ // wrong result. ACP clients must wait for the current prompt response.
234
+ if (spec.promptInFlight) return this.error(id, -32001, "session prompt already in progress");
210
235
  const prompt = textOfPrompt(params.prompt).trim();
211
236
  if (!prompt) return this.reply(id, { stopReason: "end_turn" });
212
237
  const sid = spec.acpSessionId;
238
+ spec.promptInFlight = true;
213
239
  let streamed = false;
214
240
  const events = {
215
241
  onDelta: (text) => {
@@ -232,18 +258,22 @@ class AcpAgentServer {
232
258
  },
233
259
  onStatus: () => {},
234
260
  };
235
- const res = await this.runner.runTurn(this.ctx, spec, prompt, events);
236
- if (res && res.cancelled) return this.reply(id, { stopReason: "cancelled" });
237
- if (res && res.error) {
238
- const kind = String(res.errorKind || "");
239
- if (kind === "refused") return this.reply(id, { stopReason: "refusal" });
240
- if (kind === "auth") return this.error(id, -32000, `auth_required: ${res.error}`);
241
- // Non-marker failures: surface the runtime's own words as the answer, then end the turn.
242
- if (!streamed) events.onDelta(String(res.error));
243
- return this.reply(id, { stopReason: "end_turn", _meta: { agentlas: { error: String(res.error), errorKind: kind || null } } });
261
+ try {
262
+ const res = await this.runner.runTurn(this.ctx, spec, prompt, events);
263
+ if (res && res.cancelled) return this.reply(id, { stopReason: "cancelled" });
264
+ if (res && res.error) {
265
+ const kind = String(res.errorKind || "");
266
+ if (kind === "refused") return this.reply(id, { stopReason: "refusal" });
267
+ if (kind === "auth") return this.error(id, -32000, `auth_required: ${res.error}`);
268
+ // Non-marker failures: surface the runtime's own words as the answer, then end the turn.
269
+ if (!streamed) events.onDelta(String(res.error));
270
+ return this.reply(id, { stopReason: "end_turn", _meta: { agentlas: { error: String(res.error), errorKind: kind || null } } });
271
+ }
272
+ if (!streamed && res && res.text) events.onDelta(res.text);
273
+ return this.reply(id, { stopReason: "end_turn" });
274
+ } finally {
275
+ spec.promptInFlight = false;
244
276
  }
245
- if (!streamed && res && res.text) events.onDelta(res.text);
246
- return this.reply(id, { stopReason: "end_turn" });
247
277
  }
248
278
  case "session/cancel": {
249
279
  const spec = this.sessions.get(String(params.sessionId));
@@ -110,6 +110,37 @@ function currentTargetHash(file) {
110
110
  }
111
111
  }
112
112
 
113
+ function writeTargetAtomic(file, content) {
114
+ const dir = path.dirname(file);
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ let mode = 0o600;
117
+ try {
118
+ const stat = fs.lstatSync(file);
119
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Evolution target must be a regular non-symbolic-link file");
120
+ mode = stat.mode & 0o777;
121
+ } catch (error) {
122
+ if (!error || error.code !== "ENOENT") throw error;
123
+ }
124
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
125
+ try {
126
+ fs.writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
127
+ fs.renameSync(temp, file);
128
+ try { fs.chmodSync(file, mode); } catch { /* Windows/ACL-only host */ }
129
+ } catch (error) {
130
+ try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ function restoreTargetAfterFailure(file, before, expectedCurrentHash) {
136
+ const current = currentTargetHash(file);
137
+ if (current.hash !== expectedCurrentHash) {
138
+ throw new Error("Evolution persistence failed and the target changed again before rollback; manual repair is required");
139
+ }
140
+ if (before.exists) writeTargetAtomic(file, before.content);
141
+ else fs.rmSync(file, { force: true });
142
+ }
143
+
113
144
  function printCard(out, entry, index) {
114
145
  const { row, source } = entry;
115
146
  const card = source.humanCard && typeof source.humanCard === "object" ? source.humanCard : null;
@@ -205,29 +236,37 @@ function cmdApply(db, id, out, fail, agentFolder) {
205
236
  const agent = agentById(db, row.agent_id);
206
237
  if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
207
238
  const file = targetFilePath(agentFolder, agent, row.target_path);
208
- const current = currentTargetHash(file);
209
- if (current.hash !== row.before_hash) {
239
+ const before = currentTargetHash(file);
240
+ if (before.hash !== row.before_hash) {
210
241
  return fail("Agent prompt changed after this proposal was created; review it in the desktop app and re-propose.");
211
242
  }
212
243
  const now = new Date().toISOString();
213
- fs.mkdirSync(path.dirname(file), { recursive: true });
214
- fs.writeFileSync(file, row.after_content, "utf8");
215
- const verify = sha256(fs.readFileSync(file, "utf8"));
216
- if (verify !== row.after_hash) {
217
- // 원상복구 실패 노출(폴백 금지).
218
- fs.writeFileSync(file, row.before_content, "utf8");
219
- return fail("Applied content did not match the approved hash; restored the original.");
244
+ let wroteTarget = false;
245
+ try {
246
+ runWriteTransaction(db, () => {
247
+ const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
248
+ if (!locked || locked.status !== "candidate") throw new Error("Evolution proposal changed before apply; reload and review it again");
249
+ if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Agent prompt changed before apply; reload and review it again");
250
+ writeTargetAtomic(file, row.after_content);
251
+ wroteTarget = true;
252
+ if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Applied content did not match the approved hash");
253
+ db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
254
+ insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
255
+ const changed = db.prepare(
256
+ `UPDATE agent_evolution_proposals
257
+ SET status = 'applied', applied_at = COALESCE(applied_at, ?),
258
+ last_error = NULL, updated_at = ?
259
+ WHERE id = ? AND status = 'candidate'`,
260
+ ).run(now, now, row.id).changes;
261
+ if (changed !== 1) throw new Error("Evolution proposal changed before apply; no receipt was committed");
262
+ });
263
+ } catch (error) {
264
+ if (wroteTarget) {
265
+ try { restoreTargetAfterFailure(file, before, sha256(row.after_content)); }
266
+ catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
267
+ }
268
+ return fail(`${error.message}; the original target was restored.`);
220
269
  }
221
- runWriteTransaction(db, () => {
222
- db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
223
- insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
224
- db.prepare(
225
- `UPDATE agent_evolution_proposals
226
- SET status = 'applied', applied_at = COALESCE(applied_at, ?),
227
- last_error = NULL, updated_at = ?
228
- WHERE id = ? AND status = 'candidate'`,
229
- ).run(now, now, row.id);
230
- });
231
270
  out(`applied ${id} → ${row.target_path} (agent ${row.agent_id}). Revert with: agentlas evolve revert ${id}`);
232
271
  }
233
272
 
@@ -244,27 +283,37 @@ function cmdRevert(db, id, out, fail, agentFolder) {
244
283
  const agent = agentById(db, row.agent_id);
245
284
  if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
246
285
  const file = targetFilePath(agentFolder, agent, row.target_path);
247
- const current = currentTargetHash(file);
248
- if (current.hash !== row.after_hash) {
286
+ const before = currentTargetHash(file);
287
+ if (before.hash !== row.after_hash) {
249
288
  return fail("Agent prompt changed after this proposal was applied; revert blocked to avoid clobbering newer edits.");
250
289
  }
251
290
  const now = new Date().toISOString();
252
- fs.writeFileSync(file, row.before_content, "utf8");
253
- const verify = sha256(fs.readFileSync(file, "utf8"));
254
- if (verify !== row.before_hash) {
255
- fs.writeFileSync(file, row.after_content, "utf8");
256
- return fail("Reverted content did not match the original hash; restored the applied version.");
291
+ let wroteTarget = false;
292
+ try {
293
+ runWriteTransaction(db, () => {
294
+ const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
295
+ if (!locked || !["applied", "measured"].includes(locked.status)) throw new Error("Evolution proposal changed before revert; reload it first");
296
+ if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Agent prompt changed before revert; reload it first");
297
+ writeTargetAtomic(file, row.before_content);
298
+ wroteTarget = true;
299
+ if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Reverted content did not match the original hash");
300
+ db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
301
+ insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
302
+ const changed = db.prepare(
303
+ `UPDATE agent_evolution_proposals
304
+ SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
305
+ last_error = NULL, updated_at = ?
306
+ WHERE id = ? AND status IN ('applied','measured')`,
307
+ ).run(now, now, row.id).changes;
308
+ if (changed !== 1) throw new Error("Evolution proposal changed before revert; no receipt was committed");
309
+ });
310
+ } catch (error) {
311
+ if (wroteTarget) {
312
+ try { restoreTargetAfterFailure(file, before, sha256(row.before_content)); }
313
+ catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
314
+ }
315
+ return fail(`${error.message}; the applied target was restored.`);
257
316
  }
258
- runWriteTransaction(db, () => {
259
- db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
260
- insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
261
- db.prepare(
262
- `UPDATE agent_evolution_proposals
263
- SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
264
- last_error = NULL, updated_at = ?
265
- WHERE id = ? AND status IN ('applied','measured')`,
266
- ).run(now, now, row.id);
267
- });
268
317
  out(`reverted ${id} → restored ${row.target_path} (agent ${row.agent_id}).`);
269
318
  }
270
319
 
@@ -175,13 +175,24 @@ function tableExists(db, name) {
175
175
 
176
176
  function appendRunEvent(db, input) {
177
177
  if (!db || !tableExists(db, "run_events")) return false;
178
- const id = input.id;
179
- if (db.prepare("SELECT 1 FROM run_events WHERE id=?").get(id)) return false;
180
- const seqRow = db.prepare("SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM run_events WHERE run_id=?").get(input.runId);
181
- db.prepare(
182
- "INSERT OR IGNORE INTO run_events (id,run_id,seq,ts,kind,chat_id,automation_id,node_id,agent_id,payload_json) VALUES (?,?,?,?,?,NULL,NULL,NULL,?,?)",
183
- ).run(id, input.runId, Number(seqRow?.seq || 0), input.ts, input.kind, input.agentId || null, JSON.stringify(input.payload));
184
- return true;
178
+ // Allocate seq and insert in one SQLite statement. The old SELECT(MAX)+INSERT
179
+ // pair allowed two Terminal processes to choose the same (run_id, seq); one
180
+ // INSERT OR IGNORE then vanished while this function still reported success.
181
+ const inserted = db.prepare(
182
+ `INSERT OR IGNORE INTO run_events
183
+ (id,run_id,seq,ts,kind,chat_id,automation_id,node_id,agent_id,payload_json)
184
+ SELECT ?,?,COALESCE(MAX(seq) + 1, 0),?,?,NULL,NULL,NULL,?,?
185
+ FROM run_events WHERE run_id=?`,
186
+ ).run(
187
+ input.id,
188
+ input.runId,
189
+ input.ts,
190
+ input.kind,
191
+ input.agentId || null,
192
+ JSON.stringify(input.payload),
193
+ input.runId,
194
+ );
195
+ return Number(inserted?.changes || 0) === 1;
185
196
  }
186
197
 
187
198
  function persistRunReceipt(db, receipt, agentId) {
@@ -200,7 +211,9 @@ function persistRunReceipt(db, receipt, agentId) {
200
211
  function recordIntakeDecision(db, input) {
201
212
  const sourceHash = digestHex(INTAKE_POLICY_VERSION, input.agentId, input.memoryId || "none", input.exactBase?.agentReleaseId || "none", input.environmentKey || "none");
202
213
  appendRunEvent(db, {
203
- id: opaqueId("event", "experience-intake", sourceHash),
214
+ // The same curated memory can be observed by many distinct runs. Preserve
215
+ // retry idempotency within one run without collapsing later run evidence.
216
+ id: opaqueId("event", "experience-intake", input.runId, sourceHash),
204
217
  runId: input.runId,
205
218
  ts: input.ts,
206
219
  kind: input.kind || "experience-intake-decision",
@@ -146,13 +146,53 @@ function normalizeOutcome(value) {
146
146
  return "completed";
147
147
  }
148
148
 
149
- function ownerPolicyFromPrompt(prompt) {
150
- const text = String(prompt || "");
151
- const explicitMemory = /\b(?:remember|memorize|save|record|store|keep)\b|기억|메모리|저장|기록|남겨/i.test(text);
152
- const explicitGlobal = /\b(?:global(?:ly)?|all projects|every project|across projects|user profile|account-wide)\b|전역|모든\s*프로젝트|프로젝트\s*전체|사용자\s*프로필/i.test(text);
153
- return {
154
- globalWriteAuthorized: explicitMemory && explicitGlobal,
155
- };
149
+ /*
150
+ * 2026-08-20: ownerPolicyFromPrompt(단어장 remember/전역 등 regex AND) 제거.
151
+ * 전역 메모리 쓰기 권한을 부여하는 길은 둘뿐이다:
152
+ * 1) 호스트가 넘긴 구조화 플래그(beginTurn input.ownerPolicy) 기계 표식이 우선.
153
+ * 2) 판정기(agentlas-judgment) 경유 — resolveGlobalWriteAuthorization.
154
+ * 판정 불가면 부여하지 않는다(fail-closed). 단어장은 어떤 언어도 다 못 세는 데다,
155
+ * 제3언어의 명시적 요청을 영구히 거부하고 우연한 단어 일치로 권한을 넓혔다.
156
+ */
157
+ function normalizeOwnerPolicy(value) {
158
+ return { globalWriteAuthorized: Boolean(value && value.globalWriteAuthorized === true) };
159
+ }
160
+
161
+ async function resolveGlobalWriteAuthorization(prompt, options = {}) {
162
+ const text = String(prompt || "").trim();
163
+ if (!text) return { authorized: false, source: "unavailable" };
164
+ // 호스트가 판정 함수를 주입할 수 있다(세션이 자기 연결 런타임으로 감쌈).
165
+ if (typeof options.judge === "function") {
166
+ try {
167
+ const judged = await options.judge(text);
168
+ return judged && judged.source === "llm"
169
+ ? { authorized: judged.authorized === true, source: "llm" }
170
+ : { authorized: false, source: "unavailable" };
171
+ } catch {
172
+ return { authorized: false, source: "unavailable" };
173
+ }
174
+ }
175
+ let judgment;
176
+ try {
177
+ judgment = options.judgment || require("./agentlas-judgment.cjs");
178
+ } catch {
179
+ return { authorized: false, source: "unavailable" };
180
+ }
181
+ if (!judgment.hasJudgmentRunner()) return { authorized: false, source: "unavailable" };
182
+ const verdict = await judgment.judgeLabels({
183
+ kind: "terminal-memory-global-write",
184
+ question:
185
+ "Does this request EXPLICITLY ask to save or remember something as a GLOBAL memory that applies across all projects (user profile / account-wide), rather than only this project, session, or task?",
186
+ labels: ["authorize_global_memory_write"],
187
+ input: text,
188
+ multi: false,
189
+ guidance:
190
+ "Authorize only an explicit, unambiguous request to persist a memory globally, in any language. Ordinary task prompts, project-scoped notes, or incidental mentions of memory do NOT authorize. When uncertain, select nothing.",
191
+ signal: options.signal,
192
+ timeoutMs: options.timeoutMs,
193
+ });
194
+ if (verdict.source !== "llm") return { authorized: false, source: "unavailable" };
195
+ return { authorized: verdict.labels.includes("authorize_global_memory_write"), source: "llm" };
156
196
  }
157
197
 
158
198
  function tableExists(db, name) {
@@ -245,7 +285,9 @@ function beginTurn(db, input = {}) {
245
285
  const pKey = projectKey(input.projectPath);
246
286
  const oKey = ownerKey(input.agentId);
247
287
  const explicitTurnId = validTurnId(input.stableTurnId);
248
- const policy = ownerPolicyFromPrompt(input.prompt);
288
+ // 시작 시점 정책은 호스트 구조화 플래그만 반영한다(없으면 fail-closed false).
289
+ // 판정 경유 승격은 completeTurn에서, 실제로 user_global 후보가 나왔을 때만 1회 수행된다.
290
+ const policy = normalizeOwnerPolicy(input.ownerPolicy);
249
291
  const conversationDigest = sha256(input.conversationRef || "none");
250
292
  const priorDigest = sha256(input.priorContextDigest || input.priorContext || "none");
251
293
  const contextKey = sha256(stableJson({
@@ -823,6 +865,28 @@ async function completeTurn(db, input = {}) {
823
865
  };
824
866
  }
825
867
 
868
+ // 전역 쓰기 승격은 필요할 때만 1회 — 어떤 후보가 실제로 user_global 스코프를
869
+ // 청했고, 시작 시점 정책(호스트 플래그)이 승인하지 않았을 때. 판정기(또는 호스트가
870
+ // 주입한 judge)가 명시적 요청이라고 판정한 경우에만 켠다. 판정 불가 = 부여 안 함.
871
+ if (
872
+ turn.ownerPolicy?.globalWriteAuthorized !== true
873
+ && normalizePermission(turn.permission) !== "read"
874
+ && parsed.candidates.some(
875
+ (candidate) => candidate.suggestedScope === "user_global" && candidate.preGateReasons.length === 0,
876
+ )
877
+ ) {
878
+ const judged = await resolveGlobalWriteAuthorization(input.requestText, {
879
+ judge: input.judgeGlobalAuthorization,
880
+ });
881
+ if (judged.authorized === true && judged.source === "llm") {
882
+ turn.ownerPolicy = { ...turn.ownerPolicy, globalWriteAuthorized: true };
883
+ try {
884
+ db.prepare("UPDATE terminal_memory_turn_intents SET owner_policy_json=? WHERE turn_id=?")
885
+ .run(JSON.stringify(turn.ownerPolicy), turnId);
886
+ } catch { /* 감사 기록 실패가 이번 완결을 막지는 않는다 */ }
887
+ }
888
+ }
889
+
826
890
  const payload = buildCuratorPayload(turn, parsed, input);
827
891
  let curatorStatus = "unavailable";
828
892
  let semantic = { status: "unavailable", decisions: new Map() };
@@ -878,7 +942,20 @@ async function completeTurn(db, input = {}) {
878
942
  if (!dbScope) continue;
879
943
  const scopedProjectId = decision.finalScope === "user_global" ? null : turn.projectKey;
880
944
  const scopedProjectPath = decision.finalScope === "user_global" ? null : boundProjectPath;
881
- const scopedAgentId = ["team", "agent"].includes(decision.finalScope) ? boundAgentId : null;
945
+ /*
946
+ * ★팀 공유 기억에는 주인이 없다 (2026-08-26)
947
+ *
948
+ * 이 엔진은 데스크탑과 **같은 SQLite 파일의 같은 `memory_entries` 표**를 쓴다
949
+ * (engine/core/paths.cjs — 같은 userData 공유가 제품 계약이다). 그런데 같은 "팀 공유"
950
+ * 결정을 데스크탑은 `agent_id = NULL` 로, 여기서는 `agent_id = <agentId>` 로 넣고
951
+ * 있었다. 한 표에 두 관례가 섞이면 ① 같은 사실이 주인 다른 두 줄로 남아 중복 제거가
952
+ * 갈리고 ② 정리기가 그 줄을 개인 기억으로 오인한다.
953
+ *
954
+ * 정본은 데스크탑 쪽이다 — 팀 공유는 조직도가 바뀌어도 남아야 하므로 특정 에이전트에
955
+ * 매이지 않는다. 데스크탑의 같은 규칙: shared/memory-ownership.ts `memoryOwnerAgentId`
956
+ * (`agt_team_` 낙인이거나 신원이 없으면 개인 칸이 없다).
957
+ */
958
+ const scopedAgentId = decision.finalScope === "agent" ? boundAgentId : null;
882
959
  let memoryId = null;
883
960
  try {
884
961
  const duplicate = db.prepare(
@@ -1010,7 +1087,8 @@ module.exports = {
1010
1087
  DEFAULT_EVENTS_HEADING,
1011
1088
  CURATOR_SYSTEM_PROMPT,
1012
1089
  ensureGovernanceSchema,
1013
- ownerPolicyFromPrompt,
1090
+ normalizeOwnerPolicy,
1091
+ resolveGlobalWriteAuthorization,
1014
1092
  projectKey,
1015
1093
  ownerKey,
1016
1094
  contentGateReasons,
@@ -98,4 +98,98 @@ function createCycleController(options = {}) {
98
98
  };
99
99
  }
100
100
 
101
- module.exports = { LEVELS, isLevel, normalize, persistent, next, copy, createCycleController };
101
+ /*
102
+ * ── 통합 능력 승인(데스크탑 capability_grants)과의 합류 ───────────────────────
103
+ *
104
+ * 이 모듈은 오래도록 read/write/full 세 낱말만 알았다. 그런데 오너 결정(2026-08-20)
105
+ * 이후 "무엇을 해도 되는가"의 정본은 등급이 아니라 **행동 규칙**이다: 데스크탑에서
106
+ * "항상 허용"한 행동은 read 등급에서도 통과해야 하고, 영구 거부된 행동은 full 등급으로도
107
+ * 뚫리지 않아야 한다. 등급은 규칙이 없을 때의 기본값으로 남는다.
108
+ *
109
+ * 우선순위는 데스크탑 중재자(electron/ipc.ts setRuntimeToolPermissionArbiter)와 **같은
110
+ * 문장**이다. 갈리면 같은 행동에 두 제품이 다른 답을 준다:
111
+ * 1) 저장된 규칙 deny → deny (등급 무관)
112
+ * 2) 저장된 규칙 allow → allow (등급 무관)
113
+ * 3) permission=full → allow
114
+ * 4) 비변이(mutating=false) → allow
115
+ * 5) permission=write → allow
116
+ * 6) 그 외(read + 변이) → null = 경계를 넘는 요청. 호출부가 묻거나 거부한다.
117
+ */
118
+
119
+ /** 능력 규칙 모듈은 공유 DB 를 열므로 지연 로드한다(권한 어휘만 쓰는 호출부에 부담 금지). */
120
+ function grantsModule() {
121
+ return require("./core/capability-grants.cjs");
122
+ }
123
+
124
+ /**
125
+ * 한 번의 도구/서버 실행에 대한 판정.
126
+ *
127
+ * @param {object|null} db 공유 DB 핸들. 없으면 규칙을 못 읽고 등급 기본값만 쓴다.
128
+ * @param {object} ask { capability?, kind?, tool, detail?, agentId?, chatId?, mutating?, permission? }
129
+ * @returns {{decision:"allow"|"deny"|null, source:string, ruled:"allow"|"deny"|null,
130
+ * grantsAvailable:boolean, reason:string|null, capability:string}}
131
+ */
132
+ function decideCapability(db, ask) {
133
+ const grants = grantsModule();
134
+ const capability = ask && ask.capability
135
+ ? String(ask.capability)
136
+ : grants.capabilityClassFor(String((ask && ask.kind) || ""), String((ask && ask.tool) || ""));
137
+ const query = {
138
+ capability,
139
+ tool: ask && ask.tool ? String(ask.tool) : undefined,
140
+ detail: ask && ask.detail ? String(ask.detail) : undefined,
141
+ agentId: ask && ask.agentId ? String(ask.agentId) : undefined,
142
+ chatId: ask && ask.chatId ? String(ask.chatId) : undefined,
143
+ };
144
+ const ruling = db
145
+ ? grants.readCapabilityDecision(db, query)
146
+ : { decision: null, available: false, reason: "no shared database handle was provided to the capability gate" };
147
+
148
+ if (ruling.decision === "deny") {
149
+ return { decision: "deny", source: "capability-grants", ruled: "deny", grantsAvailable: ruling.available, reason: ruling.reason, capability };
150
+ }
151
+ if (ruling.decision === "allow") {
152
+ return { decision: "allow", source: "capability-grants", ruled: "allow", grantsAvailable: ruling.available, reason: ruling.reason, capability };
153
+ }
154
+
155
+ const level = normalize(ask && ask.permission);
156
+ const mutating = !!(ask && ask.mutating);
157
+ if (level === "full") {
158
+ return { decision: "allow", source: "permission-level", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
159
+ }
160
+ if (!mutating) {
161
+ return { decision: "allow", source: "non-mutating", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
162
+ }
163
+ if (level === "write") {
164
+ return { decision: "allow", source: "permission-level", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
165
+ }
166
+ return { decision: null, source: "boundary", ruled: null, grantsAvailable: ruling.available, reason: ruling.reason, capability };
167
+ }
168
+
169
+ /**
170
+ * 터미널에서 사용자가 "항상 허용"을 골랐을 때 **같은 표**에 남긴다 — 데스크탑도 이
171
+ * 규칙을 읽으므로 다음부터 양쪽 모두 묻지 않는다. 규칙 키는 데스크탑 persistAlwaysGrant
172
+ * 와 동일: capability `tool:<name>` + 일반화된 인자 패턴 + scope global.
173
+ */
174
+ function rememberAlwaysAllow(db, ask, options = {}) {
175
+ const grants = grantsModule();
176
+ return grants.recordCapabilityGrant(db, {
177
+ capability: `tool:${String((ask && ask.tool) || "")}`,
178
+ pattern: grants.generalizeDetailPattern(ask && ask.detail),
179
+ decision: options.decision === "deny" ? "deny" : "allow",
180
+ scope: options.scope || "global",
181
+ source: options.source || "terminal-chip",
182
+ });
183
+ }
184
+
185
+ module.exports = {
186
+ LEVELS,
187
+ isLevel,
188
+ normalize,
189
+ persistent,
190
+ next,
191
+ copy,
192
+ createCycleController,
193
+ decideCapability,
194
+ rememberAlwaysAllow,
195
+ };
@@ -322,12 +322,55 @@ function allowedTools(permission) {
322
322
  return TOOLS.filter((t) => (PERM_RANK[t.minPerm] ?? 0) <= rank);
323
323
  }
324
324
 
325
+ /*
326
+ * ── 능력 규칙(공유 capability_grants)이 등급보다 먼저다 ──────────────────────
327
+ *
328
+ * 오너 결정(2026-08-20): 승인은 행동 기준이고 데스크탑·터미널이 **공유**한다.
329
+ * · 데스크탑에서 "항상 허용"한 행동 → 터미널에서 등급이 낮아도 통과(다시 묻지 않는다).
330
+ * · 데스크탑에서 영구 거부한 행동 → 터미널에서 full 권한이어도 거부.
331
+ * 규칙이 없을 때만 아래의 기존 등급 게이트가 답한다(기존 동작 그대로).
332
+ *
333
+ * ctx.db 가 없으면(단위 테스트·DB 없는 호출) 규칙을 못 읽으므로 종전 등급 게이트만 돈다.
334
+ */
335
+ function toolAskFor(tool, args) {
336
+ const kind = tool.minPerm === "read" ? "read" : tool.name === "bash" ? "execute" : "edit";
337
+ const detail = tool.name === "bash"
338
+ ? String((args && args.command) || "").trim()
339
+ : String((args && args.path) || "").trim();
340
+ return { tool: tool.name, kind, detail: detail || undefined, mutating: kind !== "read" };
341
+ }
342
+
325
343
  // 툴 1개 실행 → { ok, content }. 권한 부족/에러는 ok:false 문자열로.
326
344
  function runTool(name, args, ctx) {
327
345
  const tool = BY_NAME[name];
328
346
  if (!tool) return { ok: false, content: `unknown tool: ${name}` };
347
+ const ask = toolAskFor(tool, args);
348
+ let ruled = null;
349
+ if (ctx && ctx.db) {
350
+ try {
351
+ const permissions = require("./agentlas-permissions.cjs");
352
+ const verdict = permissions.decideCapability(ctx.db, {
353
+ ...ask,
354
+ permission: ctx.permission,
355
+ agentId: ctx.agentId,
356
+ chatId: ctx.chatId,
357
+ });
358
+ ruled = verdict.ruled;
359
+ if (ruled === "deny") {
360
+ return {
361
+ ok: false,
362
+ content:
363
+ `capability denied: '${name}'${ask.detail ? ` (${ask.detail})` : ""} is permanently denied by a shared ` +
364
+ "capability rule (Desktop/Terminal share capability_grants). Remove that rule to allow it.",
365
+ };
366
+ }
367
+ } catch {
368
+ // 규칙을 못 읽는 것이 허용이 되면 안 되고, 실행을 죽여서도 안 된다 — 기존 등급 게이트로 간다.
369
+ ruled = null;
370
+ }
371
+ }
329
372
  const rank = PERM_RANK[ctx.permission] ?? 0;
330
- if ((PERM_RANK[tool.minPerm] ?? 0) > rank) {
373
+ if (ruled !== "allow" && (PERM_RANK[tool.minPerm] ?? 0) > rank) {
331
374
  return {
332
375
  ok: false,
333
376
  content: `permission denied: '${name}' requires '${tool.minPerm}' but current is '${ctx.permission}'. Ask the user to run /permission ${tool.minPerm}.`,
@@ -355,4 +398,4 @@ function openaiTools(permission) {
355
398
  }));
356
399
  }
357
400
 
358
- module.exports = { TOOLS, BY_NAME, allowedTools, runTool, anthropicTools, openaiTools, PERM_RANK };
401
+ module.exports = { TOOLS, BY_NAME, allowedTools, runTool, anthropicTools, openaiTools, PERM_RANK, toolAskFor };
@@ -213,8 +213,8 @@ function main() {
213
213
  * 에서 ls -la 실행 실증). 가장 가까운 명령을 제안하고 정직하게 멈춘다.
214
214
  * 진짜 한 단어 작업은 따옴표+run -p 로 그대로 실행된다.
215
215
  */
216
- if (normalized.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized[0])) {
217
- const token = normalized[0];
216
+ if (commandArgv.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(commandArgv[0])) {
217
+ const token = commandArgv[0];
218
218
  const names = Object.keys(commands.COMMANDS)
219
219
  .concat(Object.keys(commands.COMMAND_ALIASES || {}))
220
220
  .concat(commands.NOT_YET_PORTED || []);
@@ -228,7 +228,10 @@ function main() {
228
228
  : `See: agentlas help · to run it as a task: agentlas run -p "${token}"`);
229
229
  process.exit(1);
230
230
  }
231
- code = commands.COMMANDS.run().run(ctx, normalized);
231
+ // 전역 출력 플래그는 이미 parseOutputFlags 가 소비했다. 원래 normalized argv 를
232
+ // 다시 넘기면 `agentlas --json "do work"`가 모델에게 "--json do work"라고
233
+ // 지시하는 꼴이 된다. 실제 명령/작업 토큰만 실행 경로로 보낸다.
234
+ code = commands.COMMANDS.run().run(ctx, commandArgv);
232
235
  }
233
236
 
234
237
  Promise.resolve(code).then(