@sigrun/sigrun 0.4.2 → 0.6.0

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 (68) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +63 -33
  3. package/dist/commands/expand.js +37 -5
  4. package/dist/curation/attribution.js +135 -0
  5. package/dist/curation/deletion.js +32 -0
  6. package/dist/curation/gate.js +50 -0
  7. package/dist/curation/host.js +342 -0
  8. package/dist/curation/internal.js +54 -0
  9. package/dist/curation/names.js +5 -0
  10. package/dist/curation/overlays.js +325 -0
  11. package/dist/curation/paths.js +17 -0
  12. package/dist/curation/run-doc.js +64 -0
  13. package/dist/curation/runs.js +579 -0
  14. package/dist/curation/scan.js +30 -0
  15. package/dist/curation/skill-tool.js +308 -0
  16. package/dist/curation/store.js +444 -0
  17. package/dist/curation/targets.js +77 -0
  18. package/dist/curation/usage.js +124 -0
  19. package/dist/curation/wrap.js +212 -0
  20. package/dist/extension.d.ts +336 -1
  21. package/dist/harness/boot.js +236 -13
  22. package/dist/harness/builtins.js +19 -12
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/identity/primary-admin.js +19 -0
  25. package/dist/harness/loader.js +114 -1
  26. package/dist/harness/naming.js +6 -0
  27. package/dist/harness/packages.js +14 -9
  28. package/dist/harness/reserved-sections.js +4 -0
  29. package/dist/harness/seam-host.js +154 -26
  30. package/dist/harness/sections/context-files.js +6 -3
  31. package/dist/harness/sections/job-state.js +24 -0
  32. package/dist/harness/settings.js +2 -2
  33. package/dist/harness/tools.js +70 -0
  34. package/dist/harness/workspace.js +30 -3
  35. package/dist/jobs/fire.js +37 -14
  36. package/dist/jobs/index.js +34 -7
  37. package/dist/jobs/reconcile.js +9 -0
  38. package/dist/jobs/skills-refs.js +40 -0
  39. package/dist/jobs/state-dir.js +16 -0
  40. package/dist/jobs/transfer.js +14 -0
  41. package/dist/sandbox/policy.js +27 -4
  42. package/dist/serve.js +5 -3
  43. package/dist/server/blocks/answers.js +9 -3
  44. package/dist/server/conversation-ops.js +34 -3
  45. package/dist/server/helpers.js +1 -1
  46. package/dist/server/host-bridge.js +31 -1
  47. package/dist/server/notifications.js +108 -16
  48. package/dist/server/routes/jobs.js +31 -0
  49. package/dist/server/routes/meta.js +8 -3
  50. package/dist/server/routes/submissions.js +5 -0
  51. package/dist/server/routes/surfaces.js +3 -1
  52. package/dist/server/routes/users.js +7 -0
  53. package/dist/server/schema-fixture.js +168 -0
  54. package/dist/server/session-meta.js +12 -10
  55. package/dist/settings/core-sections.js +18 -0
  56. package/dist/settings/registry.js +12 -0
  57. package/dist/skills/index.js +88 -19
  58. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -1
  59. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  60. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  61. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  62. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  63. package/node_modules/@sigrun/shared/package.json +1 -1
  64. package/package.json +9 -7
  65. package/webui/dist/assets/index-BR0di0OG.css +1 -0
  66. package/webui/dist/assets/{index-DWmsAQl2.js → index-BzrA1T4g.js} +5 -5
  67. package/webui/dist/index.html +2 -2
  68. package/webui/dist/assets/index-h7zDY9vD.css +0 -1
@@ -20,6 +20,7 @@ import { AgentDoc, configure } from "@earendil-works/pi-durable";
20
20
  import { accessOf, isSharing, } from "@sigrun/shared/access";
21
21
  import { extname } from "node:path";
22
22
  import { touchActivity } from "../harness/activity.js";
23
+ import { ensureRequestId, recordAuthor, } from "../curation/attribution.js";
23
24
  import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
24
25
  import { isSuperuser } from "./auth/principal.js";
25
26
  import { isConfinedPath, parseSubmissionInput } from "./content-parts.js";
@@ -145,6 +146,17 @@ export async function createConversationOp(ctx, input = {}) {
145
146
  if (input.name !== undefined && typeof input.name !== "string") {
146
147
  throw new ConversationOpError({ error: "invalid_field", field: "name" });
147
148
  }
149
+ if (input.job !== undefined) {
150
+ // Resolve the live job (and therefore its EFFECTIVE owner) before the
151
+ // createConversation table write or the session-meta commit. An absent
152
+ // requested owner and an absent effective owner are an exact match.
153
+ const job = typeof input.job === "string"
154
+ ? await opened.jobs.get(input.job)
155
+ : undefined;
156
+ if (job === undefined || input.owner !== job.owner) {
157
+ throw new ConversationOpError({ error: "invalid_field", field: "job" });
158
+ }
159
+ }
148
160
  if (input.source !== undefined && typeof input.source !== "string") {
149
161
  throw new ConversationOpError({ error: "invalid_field", field: "source" });
150
162
  }
@@ -226,6 +238,8 @@ export async function createConversationOp(ctx, input = {}) {
226
238
  }
227
239
  if (input.name !== undefined)
228
240
  draft.name = input.name;
241
+ if (input.job !== undefined)
242
+ draft.jobId = input.job;
229
243
  if (input.source !== undefined)
230
244
  draft.source = input.source;
231
245
  await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
@@ -247,6 +261,9 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
247
261
  if (!parsed.ok) {
248
262
  throw new ConversationOpError({ error: "invalid_input" });
249
263
  }
264
+ // Every input carries a requestId (curator P1): the caller's own id, or
265
+ // a generated `sigrun:` UUID — the pre-admission attribution key.
266
+ const requestId = ensureRequestId(opts.requestId);
250
267
  // Spec §2/§3 binding contract: a conversation pinned to an unserved
251
268
  // model fails explicitly AT SUBMIT — 4xx with the available ids —
252
269
  // never a silent fallback, and never a settled no_model run surfaced
@@ -316,7 +333,11 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
316
333
  else {
317
334
  try {
318
335
  const env = await envThunk();
319
- expansion = await expandInput(parsed.parts, opened.commandExpansion, env, BACKGROUND_CONTEXT);
336
+ // Curator A3: the learned root resolves through the conversation
337
+ // OWNER (SessionMetaDoc), not the submitting user — a shared-write
338
+ // submitter expands against the owner's learned skills.
339
+ const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
340
+ expansion = await expandInput(parsed.parts, opened.commandExpansion, env, BACKGROUND_CONTEXT, meta?.owner);
320
341
  }
321
342
  catch (error) {
322
343
  if (error instanceof CommandUsageError) {
@@ -356,13 +377,20 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
356
377
  const draft = {
357
378
  type: "input",
358
379
  content,
359
- ...(opts.requestId === undefined ? {} : { requestId: opts.requestId }),
380
+ requestId,
360
381
  ...(whenBusy === undefined ? {} : { whenBusy: whenBusy }),
361
382
  ...(entry === undefined ? {} : { entry }),
362
383
  };
384
+ // Attribution (curator P1), two writes per input: `byRequest` BEFORE the
385
+ // admission (the requestId exists pre-admission, so this never races; an
386
+ // orphan record from a failed submit is harmless) and `bySubmission`
387
+ // right after it returns — the key the live run's input ids resolve
388
+ // against. An `undefined` user records nothing (unattributed).
389
+ await recordAuthor(conversation, { requestId }, opts.user, BACKGROUND_CONTEXT);
363
390
  // The submit pass-through is retired (§6.7): the memory seam's
364
391
  // runSettled owns capture, so submissions admit directly.
365
392
  const submission = await conversation.submit(draft, BACKGROUND_CONTEXT);
393
+ await recordAuthor(conversation, { submissionId: submission.id }, opts.user, BACKGROUND_CONTEXT);
366
394
  // Admission stamps activity: the sidebar's activity sort must move the
367
395
  // moment a prompt lands — before the run settles.
368
396
  await touchActivity(opened.harness, conversation.id, opened.now, BACKGROUND_CONTEXT);
@@ -394,7 +422,10 @@ export async function listConversationsOp(ctx, caller, filters = {}) {
394
422
  // flags, and the create source (spec §9.5).
395
423
  const meta = await opened.harness.snapshot(SessionMetaDoc, record.id, BACKGROUND_CONTEXT);
396
424
  const access = accessOf(caller, meta ?? {});
397
- // Hidden is absent from the list (spec §5.9).
425
+ // Hidden is absent from the list (spec §5.9) — and internal means
426
+ // hidden for EVERY caller (curator P5, accessOf rule 0): the
427
+ // superuser and the bridge's trusted read are covered by the same
428
+ // line, so no caller-specific skip can drift.
398
429
  if (access === "none")
399
430
  continue;
400
431
  // The archived filter (absent = all).
@@ -86,7 +86,7 @@ function autoEnableRemovals(settings, env) {
86
86
  const server = entry;
87
87
  if (server.enabled === false || server.disabled === true)
88
88
  continue;
89
- // @sigrun/ext-mcp's server default is "codemode"; its pi-compat
89
+ // @sigrun/ext-mcp's server default is "codemode"; its deprecated
90
90
  // alias `codemode-deferred` normalizes to "codemode".
91
91
  const exposure = server.exposure === "codemode-deferred"
92
92
  ? "codemode"
@@ -22,6 +22,7 @@ import { userWire } from "./routes/users.js";
22
22
  import { accessPrincipalOf } from "./auth/access-principal.js";
23
23
  import { abortOp, compactOp, configureOp, ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readFileOp, readMetaOp, submitOp, uploadOp, } from "./conversation-ops.js";
24
24
  import { listModels } from "./helpers.js";
25
+ import { notifyExtension } from "./notifications.js";
25
26
  import { SessionMetaDoc } from "./session-meta.js";
26
27
  import { accountLinkWire, serializeConversationView } from "./views.js";
27
28
  /**
@@ -59,11 +60,13 @@ export function createHostBridge(ctx) {
59
60
  const conversation = await conversationOf(id);
60
61
  // `user` is accepted and NOT verified: extensions own their
61
62
  // user mapping (spec §9.5) — a channel's unmapped user submits
62
- // fine, and owner attribution happened at create.
63
+ // fine, and owner attribution happened at create. P1 records
64
+ // it verbatim as the input's author.
63
65
  return await submitOp(ctx, conversation, input, {
64
66
  ...(opts?.requestId === undefined
65
67
  ? {}
66
68
  : { requestId: opts.requestId }),
69
+ ...(opts?.user === undefined ? {} : { user: opts.user }),
67
70
  ...(opts?.whenBusy === undefined
68
71
  ? {}
69
72
  : { whenBusy: opts.whenBusy }),
@@ -106,6 +109,15 @@ export function createHostBridge(ctx) {
106
109
  await conversationOf(id);
107
110
  return await readMetaOp(opened, id);
108
111
  },
112
+ async entries(id, query, limit, cursor) {
113
+ const conversation = await conversationOf(id);
114
+ return await conversation.entries(query, limit, cursor, BACKGROUND_CONTEXT);
115
+ },
116
+ async entry(id, entryId) {
117
+ const conversation = await conversationOf(id);
118
+ const page = await conversation.entries({ minEntryId: entryId, maxEntryId: entryId }, 1, undefined, BACKGROUND_CONTEXT);
119
+ return page.items[0];
120
+ },
109
121
  // Gateway C4a: the remaining conversation ops, each the SAME op
110
122
  // its HTTP route runs — resolved through conversationOf (unknown id
111
123
  // → the bridge's loud `conversation <id> not found`).
@@ -168,6 +180,20 @@ export function createHostBridge(ctx) {
168
180
  return await readFileOp(ctx, conversation, path);
169
181
  },
170
182
  },
183
+ jobs: {
184
+ async get(id) {
185
+ const job = await opened.jobs.get(id);
186
+ if (job === undefined)
187
+ return undefined;
188
+ return {
189
+ id: job.id,
190
+ name: job.name,
191
+ ...(job.owner === undefined ? {} : { owner: job.owner }),
192
+ enabled: job.enabled,
193
+ state: job.state,
194
+ };
195
+ },
196
+ },
171
197
  users: {
172
198
  // The directory in wire form (emails are admin material; the
173
199
  // bridge is in-process server code, the same trust as the
@@ -277,6 +303,7 @@ export function createHostBridge(ctx) {
277
303
  : { notice: result.notice };
278
304
  },
279
305
  },
306
+ notify: (slug, input) => notifyExtension(ctx, slug, input),
280
307
  surfaces: {
281
308
  // UI-blocks U3 Task 4 (spec §7.3): the same bumpRev the actions
282
309
  // route takes — the rev advance and the hub event are one step.
@@ -284,5 +311,8 @@ export function createHostBridge(ctx) {
284
311
  ctx.surfaces.bumpRev(slug, id, key, update);
285
312
  },
286
313
  },
314
+ // Curator P6 (A9): the boot-built curation host, verbatim — the
315
+ // ONE instance every seam host reaches through this bridge.
316
+ curation: ctx.opened.curationHost,
287
317
  };
288
318
  }
@@ -40,6 +40,29 @@ import { SessionMetaDoc } from "./session-meta.js";
40
40
  function displayName(meta, id) {
41
41
  return meta?.name ?? meta?.autoName ?? `Conversation ${id}`;
42
42
  }
43
+ /** Collect every loaded notifier with its contributing extension's host. */
44
+ export function collectTargets(ctx) {
45
+ const targets = [];
46
+ for (const binding of ctx.opened.notifierBindings) {
47
+ // The Task 2 ext-route host semantics: the captured seam host,
48
+ // cast across the not-yet-bridged members.
49
+ const host = ctx.opened.seamHosts.get(binding.extension);
50
+ for (const notifier of binding.notifiers) {
51
+ targets.push({ extension: binding.extension, notifier, host });
52
+ }
53
+ }
54
+ return targets;
55
+ }
56
+ /** Enqueue one per-recipient event through every notifier (never awaited). */
57
+ export function deliverEvent(ctx, targets, event) {
58
+ for (const target of targets) {
59
+ // Fire-and-forget per transport call: one notifier's failure or
60
+ // hang neither blocks nor loses the others' deliveries.
61
+ void ctx.opened.eventBus.runBounded(`notifier ${target.extension}.${target.notifier.name}`, () => target.notifier.notify(event, {
62
+ host: target.host,
63
+ }));
64
+ }
65
+ }
43
66
  /**
44
67
  * Start the notifications surface. Returns its unsubscribe (the server
45
68
  * never calls it today — the surface rides the process lifetime — but
@@ -48,15 +71,7 @@ function displayName(meta, id) {
48
71
  */
49
72
  export function startNotifications(ctx) {
50
73
  const { opened, config } = ctx;
51
- const targets = [];
52
- for (const binding of opened.notifierBindings) {
53
- // The Task 2 ext-route host semantics: the captured seam host,
54
- // cast across the not-yet-bridged members.
55
- const host = opened.seamHosts.get(binding.extension);
56
- for (const notifier of binding.notifiers) {
57
- targets.push({ extension: binding.extension, notifier, host });
58
- }
59
- }
74
+ const targets = collectTargets(ctx);
60
75
  if (targets.length === 0)
61
76
  return () => { };
62
77
  const bus = opened.eventBus;
@@ -83,13 +98,7 @@ export function startNotifications(ctx) {
83
98
  // the conditional spread keeps the wire shape exact).
84
99
  ...(base.outcome === undefined ? {} : { outcome: base.outcome }),
85
100
  };
86
- for (const target of targets) {
87
- // Fire-and-forget per transport call: one notifier's failure or
88
- // hang neither blocks nor loses the others' deliveries.
89
- void bus.runBounded(`notifier ${target.extension}.${target.notifier.name}`, () => target.notifier.notify(event, {
90
- host: target.host,
91
- }));
92
- }
101
+ deliverEvent(ctx, targets, event);
93
102
  }
94
103
  };
95
104
  /** One conversation's live SessionMeta (undefined on absence or read failure, logged). */
@@ -132,6 +141,12 @@ export function startNotifications(ctx) {
132
141
  if (mode === "never")
133
142
  return;
134
143
  const meta = await metaOf(conversationId);
144
+ // Curator P5: an internal run never notifies (no "Reply ready" for
145
+ // curator work). The bus already withholds the host event; this is
146
+ // the belt-and-braces read-side gate (a hand-published or future
147
+ // emit path cannot leak the run).
148
+ if (meta?.internal === true)
149
+ return;
135
150
  if (mode === "cron" && meta?.source !== "cron")
136
151
  return;
137
152
  if (mode === "unwatched" &&
@@ -204,3 +219,80 @@ export function startNotifications(ctx) {
204
219
  });
205
220
  return unsubscribe;
206
221
  }
222
+ /**
223
+ * Validate and enqueue one extension notice. The caller's promise resolves
224
+ * after validation and enqueueing, not after any transport has delivered.
225
+ */
226
+ export async function notifyExtension(ctx, slug, input) {
227
+ const users = ctx.opened.identity.users;
228
+ if (users.isClaimed()) {
229
+ if (input.user === undefined) {
230
+ throw new Error("notification user is required when the directory is non-empty");
231
+ }
232
+ if (users.get(input.user) === undefined) {
233
+ throw new Error(`unknown user "${input.user}"`);
234
+ }
235
+ }
236
+ if (typeof input.title !== "string" ||
237
+ input.title.length < 1 ||
238
+ input.title.length > 200) {
239
+ throw new Error("notification title must be 1..200 characters");
240
+ }
241
+ if (typeof input.body !== "string" ||
242
+ input.body.length > 2000) {
243
+ throw new Error("notification body must be 0..2000 characters");
244
+ }
245
+ if (!/^[a-z0-9.-]{1,64}$/.test(input.kind)) {
246
+ throw new Error("notification kind must be 1..64 characters from [a-z0-9.-]");
247
+ }
248
+ if (input.target !== undefined) {
249
+ if (typeof input.target !== "object" || input.target === null || Array.isArray(input.target)) {
250
+ throw new Error("notification target must be an object");
251
+ }
252
+ const hasView = "view" in input.target;
253
+ const hasConversation = "conversation" in input.target;
254
+ if (hasView === hasConversation) {
255
+ throw new Error("notification target must have exactly one of view or conversation");
256
+ }
257
+ if ((hasView && typeof input.target.view !== "string") ||
258
+ (hasConversation && typeof input.target.conversation !== "number")) {
259
+ throw new Error("notification target requires a string view or a number conversation");
260
+ }
261
+ }
262
+ let conversationId;
263
+ let url;
264
+ if (input.target !== undefined && "view" in input.target) {
265
+ const view = input.target.view;
266
+ const binding = ctx.opened.extensionSurfaces.find((candidate) => candidate.extension === slug);
267
+ const declared = binding?.defs.some((def) => def.placement === "view" && def.id === view);
268
+ if (declared !== true) {
269
+ throw new Error(`notification view "${view}" was not declared by extension "${slug}"`);
270
+ }
271
+ if (ctx.config.publicUrl !== undefined) {
272
+ const base = `${ctx.config.publicUrl}/#/ext/${encodeURIComponent(slug)}/` +
273
+ encodeURIComponent(view);
274
+ const params = input.target.params ?? {};
275
+ url = Object.keys(params).length === 0
276
+ ? base
277
+ : `${base}?params=${encodeURIComponent(JSON.stringify(params))}`;
278
+ }
279
+ }
280
+ else {
281
+ conversationId = input.target?.conversation;
282
+ if (conversationId !== undefined && ctx.config.publicUrl !== undefined) {
283
+ url = `${ctx.config.publicUrl}/#/c/${conversationId}`;
284
+ }
285
+ }
286
+ const event = {
287
+ type: "extension",
288
+ extension: slug,
289
+ kind: input.kind,
290
+ conversationId,
291
+ jobId: undefined,
292
+ user: input.user,
293
+ title: input.title,
294
+ body: input.body,
295
+ ...(url === undefined ? {} : { url }),
296
+ };
297
+ deliverEvent(ctx, collectTargets(ctx), event);
298
+ }
@@ -212,6 +212,37 @@ export function registerJobRoutes(app, ctx) {
212
212
  return await domainError(reply, error);
213
213
  }
214
214
  });
215
+ // POST /v1/jobs/:id/state/reset — write access; any live job source.
216
+ // The job's definition and history stay intact; only its persistent
217
+ // state folder is removed. Unlike delete/transfer/reconcile cleanup,
218
+ // an explicit reset surfaces a failed removal to the caller.
219
+ app.post("/v1/jobs/:id/state/reset", async (request, reply) => {
220
+ const { id } = request.params;
221
+ const wire = await jobs.get(id);
222
+ if (wire === undefined) {
223
+ return await deny(reply, "job_not_found");
224
+ }
225
+ const access = accessOf(accessPrincipal(request), resourceOf(wire));
226
+ if (access === "none") {
227
+ return await deny(reply, "job_not_found");
228
+ }
229
+ if (access === "read") {
230
+ return await deny(reply, "forbidden");
231
+ }
232
+ try {
233
+ await jobs.resetState(id);
234
+ return await reply.code(204).send();
235
+ }
236
+ catch (error) {
237
+ if (error instanceof JobNotFoundError) {
238
+ return await deny(reply, "job_not_found");
239
+ }
240
+ return await reply.code(500).send({
241
+ error: "state_reset_failed",
242
+ message: error instanceof Error ? error.message : String(error),
243
+ });
244
+ }
245
+ });
215
246
  // POST /v1/jobs/:id/run — write access; fires now (overlap honored).
216
247
  // A LIVE record is required (like PATCH/DELETE): a retired job's
217
248
  // revision never satisfies run-now — 404, never fireJob's blocked-200
@@ -178,8 +178,8 @@ export function registerMetaRoutes(app, ctx) {
178
178
  // templates appear the moment their files land, no reload command. The
179
179
  // core + extension halves remain boot constants; see `commands.ts` for
180
180
  // the scope rulings.
181
- app.get("/v1/commands", async () => {
182
- // The workspace root (the first of the three) reads through a
181
+ app.get("/v1/commands", async (request) => {
182
+ // The workspace root (the first of the four) reads through a
183
183
  // workspace env, exactly like skill discovery at generation time;
184
184
  // the catalog scan uses the root conversation's env — the static
185
185
  // workspace every conversation resolves to today.
@@ -188,7 +188,12 @@ export function registerMetaRoutes(app, ctx) {
188
188
  if (env === undefined) {
189
189
  throw new Error("envBuilder returned no ExecutionEnv");
190
190
  }
191
- const skills = await listSkills(env, BACKGROUND_CONTEXT, expansion.skills);
191
+ // Curator A3: learned skills are the CALLER's — a user's `/skill:`
192
+ // menu shows their own learned skills and nobody else's (an
193
+ // unidentified caller sees none).
194
+ const skills = await listSkills(env, BACKGROUND_CONTEXT, expansion.skills, {
195
+ owner: request.principal.user,
196
+ });
192
197
  const templates = expandableTemplates(await discoverTemplates(env, BACKGROUND_CONTEXT, expansion.templates), expansion.commandNames());
193
198
  return {
194
199
  commands: [
@@ -46,6 +46,11 @@ export function registerSubmissionRoutes(app, ctx) {
46
46
  ...(body.requestId === undefined
47
47
  ? {}
48
48
  : { requestId: String(body.requestId) }),
49
+ // Attribution (curator P1): the resolved principal's user is
50
+ // the input's author; an unidentified caller records nothing.
51
+ ...(request.principal.user === undefined
52
+ ? {}
53
+ : { user: request.principal.user }),
49
54
  ...(body.whenBusy === undefined ? {} : { whenBusy: body.whenBusy }),
50
55
  ...(request.query?.waitMs ===
51
56
  undefined
@@ -508,7 +508,9 @@ export function registerSurfaceRoutes(app, ctx) {
508
508
  if (chatActionStartsTurn(block.tree, body.action)) {
509
509
  const answerPayload = (body.payload ?? null);
510
510
  const wrapped = { ok: true, rev: block.state.rev };
511
- await answerBlock(ctx, id, entryId, body.action, answerPayload, actionId);
511
+ await answerBlock(ctx, id, entryId, body.action, answerPayload, actionId,
512
+ // The acting user authors the answer turn (curator P1).
513
+ request.principal.user);
512
514
  if (actionId !== undefined) {
513
515
  ctx.surfaces.chatDedupRecord(actionId, address, fingerprint, wrapped);
514
516
  }
@@ -16,6 +16,7 @@ import { withClaimLock } from "../auth/claim-lock.js";
16
16
  import { effectiveAdmin, storedGroups } from "../../harness/identity/groups.js";
17
17
  import { transferOwnership } from "../ownership.js";
18
18
  import { validateGrantPath } from "../../sandbox/grants.js";
19
+ import { removeUserCuration } from "../../curation/deletion.js";
19
20
  /** The wire form of one directory user. `admin` stays the STORED flag;
20
21
  * `groupAdmin` is DERIVED from stored groups and retained for one release
21
22
  * so the users UI keeps rendering. */
@@ -265,6 +266,12 @@ export function registerUserRoutes(app, ctx) {
265
266
  // §10.6 step 5: data/state stay for the recipient; core owns only
266
267
  // the departing user's credentials, settings, and every cache slug.
267
268
  await ctx.opened.extensionStores.removeUserData(user);
269
+ // Curator A10 (P2): the departing user's learned skills and curation
270
+ // state are REMOVED, never transferred — a skill distilled from
271
+ // their conversations can carry their paths and preferences, so it
272
+ // must not load for the recipient. In-flight curator runs fail
273
+ // closed through the stores' user-exists gate (nothing recreated).
274
+ await removeUserCuration(ctx.managedPaths.userAreas(user));
268
275
  return await reply.code(204).send();
269
276
  });
270
277
  }
@@ -336,6 +336,174 @@ export const VIEW_SCHEMA = {
336
336
  }
337
337
  }
338
338
  },
339
+ {
340
+ "kind": "object",
341
+ "fields": {
342
+ "name": {
343
+ "kind": "string"
344
+ },
345
+ "description": {
346
+ "kind": "string"
347
+ },
348
+ "parameters": {
349
+ "kind": "object",
350
+ "fields": {
351
+ "type": {
352
+ "kind": "string"
353
+ },
354
+ "required": {
355
+ "kind": "array",
356
+ "variants": [
357
+ {
358
+ "kind": "string"
359
+ }
360
+ ]
361
+ },
362
+ "properties": {
363
+ "kind": "object",
364
+ "fields": {
365
+ "action": {
366
+ "kind": "object",
367
+ "fields": {
368
+ "anyOf": {
369
+ "kind": "array",
370
+ "variants": [
371
+ {
372
+ "kind": "object",
373
+ "fields": {
374
+ "type": {
375
+ "kind": "string"
376
+ },
377
+ "const": {
378
+ "kind": "string"
379
+ }
380
+ }
381
+ }
382
+ ]
383
+ },
384
+ "description": {
385
+ "kind": "string"
386
+ }
387
+ }
388
+ },
389
+ "name": {
390
+ "kind": "object",
391
+ "fields": {
392
+ "type": {
393
+ "kind": "string"
394
+ },
395
+ "description": {
396
+ "kind": "string"
397
+ }
398
+ }
399
+ },
400
+ "file_path": {
401
+ "kind": "object",
402
+ "fields": {
403
+ "type": {
404
+ "kind": "string"
405
+ },
406
+ "description": {
407
+ "kind": "string"
408
+ }
409
+ }
410
+ },
411
+ "content": {
412
+ "kind": "object",
413
+ "fields": {
414
+ "type": {
415
+ "kind": "string"
416
+ },
417
+ "description": {
418
+ "kind": "string"
419
+ }
420
+ }
421
+ },
422
+ "old_text": {
423
+ "kind": "object",
424
+ "fields": {
425
+ "type": {
426
+ "kind": "string"
427
+ },
428
+ "description": {
429
+ "kind": "string"
430
+ }
431
+ }
432
+ },
433
+ "new_text": {
434
+ "kind": "object",
435
+ "fields": {
436
+ "type": {
437
+ "kind": "string"
438
+ },
439
+ "description": {
440
+ "kind": "string"
441
+ }
442
+ }
443
+ },
444
+ "absorbed_into": {
445
+ "kind": "object",
446
+ "fields": {
447
+ "type": {
448
+ "kind": "string"
449
+ },
450
+ "description": {
451
+ "kind": "string"
452
+ }
453
+ }
454
+ },
455
+ "reason": {
456
+ "kind": "object",
457
+ "fields": {
458
+ "type": {
459
+ "kind": "string"
460
+ },
461
+ "description": {
462
+ "kind": "string"
463
+ }
464
+ }
465
+ },
466
+ "overlay_root": {
467
+ "kind": "object",
468
+ "fields": {
469
+ "anyOf": {
470
+ "kind": "array",
471
+ "variants": [
472
+ {
473
+ "kind": "object",
474
+ "fields": {
475
+ "type": {
476
+ "kind": "string"
477
+ },
478
+ "const": {
479
+ "kind": "string"
480
+ }
481
+ }
482
+ }
483
+ ]
484
+ },
485
+ "description": {
486
+ "kind": "string"
487
+ }
488
+ }
489
+ },
490
+ "remove": {
491
+ "kind": "object",
492
+ "fields": {
493
+ "type": {
494
+ "kind": "string"
495
+ },
496
+ "description": {
497
+ "kind": "string"
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+ },
339
507
  {
340
508
  "kind": "object",
341
509
  "fields": {