@sigrun/sigrun 0.4.1 → 0.5.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 (56) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +46 -23
  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 +282 -0
  21. package/dist/harness/boot.js +209 -9
  22. package/dist/harness/builtins.js +12 -9
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/loader.js +114 -1
  25. package/dist/harness/naming.js +6 -0
  26. package/dist/harness/packages.js +14 -9
  27. package/dist/harness/reserved-sections.js +2 -0
  28. package/dist/harness/seam-host.js +139 -26
  29. package/dist/harness/settings.js +2 -2
  30. package/dist/harness/tools.js +70 -0
  31. package/dist/jobs/fire.js +33 -11
  32. package/dist/jobs/skills-refs.js +40 -0
  33. package/dist/sandbox/policy.js +14 -0
  34. package/dist/server/blocks/answers.js +9 -3
  35. package/dist/server/conversation-ops.js +21 -3
  36. package/dist/server/helpers.js +1 -1
  37. package/dist/server/host-bridge.js +6 -1
  38. package/dist/server/notifications.js +6 -0
  39. package/dist/server/routes/meta.js +8 -3
  40. package/dist/server/routes/submissions.js +5 -0
  41. package/dist/server/routes/surfaces.js +3 -1
  42. package/dist/server/routes/users.js +7 -0
  43. package/dist/server/schema-fixture.js +168 -0
  44. package/dist/server/session-meta.js +12 -10
  45. package/dist/settings/core-sections.js +9 -0
  46. package/dist/settings/registry.js +6 -0
  47. package/dist/skills/index.js +88 -19
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -1
  49. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  50. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  51. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  52. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  53. package/node_modules/@sigrun/shared/package.json +3 -2
  54. package/package.json +9 -7
  55. package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
  56. package/webui/dist/index.html +1 -1
@@ -949,13 +949,18 @@ export async function syncGit(source, ref, gitDir, options = {}) {
949
949
  return target;
950
950
  }
951
951
  /**
952
- * The pi-compat `.ts` skip warning (spec §9.4): a `pi.extensions` entry
953
- * ending in `.ts` cannot load under plain node (the build spec removed
954
- * the `.ts` entry fallback) — it is skipped, keeping the package's
955
- * skills loadable. ONE line per skipped entry, per discovery.
952
+ * The `.ts` skip warning (spec §9.4): a `pi.extensions` entry ending in
953
+ * `.ts` cannot load under plain node (the build spec removed the `.ts`
954
+ * entry fallback) — it is skipped, keeping the package's skills loadable.
955
+ * ONE line per skipped entry, per discovery.
956
+ *
957
+ * The wording says `pi` MANIFEST rather than "pi extension" on purpose:
958
+ * the `pi` key is read only to LOCATE a file. Sigrun does not implement
959
+ * pi's extension API, so this skip is not "pi extensions don't work here";
960
+ * it is "this package's entry is unimportable by plain node".
956
961
  */
957
962
  function warnTsPiExtension(entry) {
958
- console.warn(`pi-compat: skipping TypeScript pi extension ${entry} (not loadable under node); loading the package's skills only`);
963
+ console.warn(`pi manifest: skipping TypeScript entry ${entry} (not loadable under node); loading the package's skills only`);
959
964
  }
960
965
  /** Read one package root's manifest (absent/unreadable → undefined). */
961
966
  function readDiscoveryManifest(pkgRoot) {
@@ -979,9 +984,9 @@ function manifestStringArray(value) {
979
984
  }
980
985
  /**
981
986
  * A package root's manifest skill dirs (spec §9.4): `sigrun.skills`
982
- * (string[]) else pi-compat `pi.skills` (string[] — same SKILL.md format,
983
- * so no warning), each resolved against the package root, keeping only
984
- * EXISTING directories. No manifest, no field, or nothing existing → [].
987
+ * (string[]) else the `pi`-shaped `pi.skills` (string[] — same SKILL.md
988
+ * format, so no warning), each resolved against the package root, keeping
989
+ * only EXISTING directories. No manifest, no field, or nothing existing → [].
985
990
  */
986
991
  export function manifestSkills(pkgRoot) {
987
992
  const manifest = readDiscoveryManifest(pkgRoot);
@@ -1035,7 +1040,7 @@ export function manifestEntry(pkgRoot) {
1035
1040
  }
1036
1041
  return join(pkgRoot, entry);
1037
1042
  }
1038
- console.warn(`pi-compat: no loadable pi extension in ${pkgRoot} (all entries are TypeScript); loading the package's skills only`);
1043
+ console.warn(`pi manifest: no loadable entry in ${pkgRoot} (all entries are TypeScript); loading the package's skills only`);
1039
1044
  }
1040
1045
  return undefined;
1041
1046
  }
@@ -38,6 +38,8 @@ export const RESERVED_EXTENSION_SLUGS = [
38
38
  "user-settings",
39
39
  "user-secrets",
40
40
  "skills",
41
+ "versions",
42
+ "archive",
41
43
  "prompts",
42
44
  "packages",
43
45
  "git",
@@ -37,6 +37,28 @@ export class SeamError extends Error {
37
37
  * boot-built store trio (one per boot, from the resolved roots); the
38
38
  * host scopes it to its identity's slug — `rekey` re-scopes in place.
39
39
  */
40
+ /**
41
+ * The pre-bind forwarder for the curation surface's SYNC store members
42
+ * (`skills(user)`, `overlays(user)`): returns the store handle at once;
43
+ * every method call awaits the bridge first (the shared pending promise
44
+ * carries the bind deadline). Method names are read off the resolved
45
+ * store at call time, so the forwarder stays shape-stable.
46
+ */
47
+ function syncStoreForwarder(pending, member, user) {
48
+ return new Proxy({}, {
49
+ get(_t, method) {
50
+ return async (...args) => {
51
+ const resolvedHost = (await pending);
52
+ const store = resolvedHost[member](user);
53
+ const fn = store[method];
54
+ if (typeof fn !== "function") {
55
+ throw new Error(`curation: store ${member}(${user}) method ${String(method)} is not callable`);
56
+ }
57
+ return fn.apply(store, [...args]);
58
+ };
59
+ },
60
+ });
61
+ }
40
62
  export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services) {
41
63
  const interactionKinds = new Set(kinds.interaction ?? []);
42
64
  const ambientKinds = new Set(kinds.ambient ?? []);
@@ -79,10 +101,31 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
79
101
  const bridgePromise = new Promise((resolve) => {
80
102
  bridgeReleased = resolve;
81
103
  });
82
- const hostBridge = async () => {
104
+ // The pre-bind bound: a bridge call that waits PAST this long fails
105
+ // loud instead of hanging forever (the jobs-task fake-clock hang's
106
+ // lesson). Production never sees it — `createServer` binds the bridge
107
+ // at boot, long inside the bound — but a serverless embed that never
108
+ // builds a server would otherwise await an eternally pending promise.
109
+ const bridgeDeadlineMs = 60_000;
110
+ const hostBridge = async (member) => {
83
111
  await bind.harness();
84
- return bridgePromise;
112
+ return await Promise.race([
113
+ bridgePromise,
114
+ new Promise((_, reject) => {
115
+ const timer = setTimeout(() => {
116
+ reject(new Error(`seam host: ${member} — the bridge never bound after ${bridgeDeadlineMs}ms; is createServer running? (caller: ${identity.name})`));
117
+ }, bridgeDeadlineMs);
118
+ // The rejection timer never keeps the process alive.
119
+ if (typeof timer === "object" && "unref" in timer)
120
+ timer.unref();
121
+ }),
122
+ ]);
85
123
  };
124
+ // The curation surface's settled bridge host (curator P6/A9): memoized
125
+ // HERE — in the host closure — so every `host.curation` getter call (a
126
+ // FRESH proxy per read) shares one settled reference. See the getter's
127
+ // own comment for the sync-member discipline this serves.
128
+ let curationHost;
86
129
  return {
87
130
  get stores() {
88
131
  return { ...stores, user: userStores };
@@ -273,7 +316,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
273
316
  // re-keyed current slug, read at call time).
274
317
  conversations: {
275
318
  async create(opts = {}) {
276
- const bridge = await hostBridge();
319
+ const bridge = await hostBridge("conversations.create");
277
320
  return bridge.conversations.create({
278
321
  ...(opts.owner === undefined ? {} : { owner: opts.owner }),
279
322
  ...(opts.sharing === undefined ? {} : { sharing: opts.sharing }),
@@ -284,90 +327,160 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
284
327
  });
285
328
  },
286
329
  async submit(id, input, opts) {
287
- const bridge = await hostBridge();
330
+ const bridge = await hostBridge("conversations.submit");
288
331
  return bridge.conversations.submit(id, input, opts);
289
332
  },
290
333
  async watch(id, listener) {
291
- const bridge = await hostBridge();
334
+ const bridge = await hostBridge("conversations.watch");
292
335
  return bridge.conversations.watch(id, listener);
293
336
  },
294
337
  async list(opts) {
295
- const bridge = await hostBridge();
338
+ const bridge = await hostBridge("conversations.list");
296
339
  return bridge.conversations.list(opts);
297
340
  },
298
341
  async meta(id) {
299
- const bridge = await hostBridge();
342
+ const bridge = await hostBridge("conversations.meta");
300
343
  return bridge.conversations.meta(id);
301
344
  },
302
345
  // The C4a ops: straight delegation (the bridge resolves the id and
303
346
  // runs the op its HTTP twin runs).
304
347
  async abort(id) {
305
- return (await hostBridge()).conversations.abort(id);
348
+ return (await hostBridge("conversations.abort")).conversations.abort(id);
306
349
  },
307
350
  async configure(id, opts) {
308
- return (await hostBridge()).conversations.configure(id, opts);
351
+ return (await hostBridge("conversations.configure")).conversations.configure(id, opts);
309
352
  },
310
353
  async compact(id) {
311
- return (await hostBridge()).conversations.compact(id);
354
+ return (await hostBridge("conversations.compact")).conversations.compact(id);
312
355
  },
313
356
  async fork(id, opts) {
314
- return (await hostBridge()).conversations.fork(id, opts);
357
+ return (await hostBridge("conversations.fork")).conversations.fork(id, opts);
315
358
  },
316
359
  async patchMeta(id, patch) {
317
- return (await hostBridge()).conversations.patchMeta(id, patch);
360
+ return (await hostBridge("conversations.patchMeta")).conversations.patchMeta(id, patch);
318
361
  },
319
362
  // The C4b ops: same straight delegation.
320
363
  async upload(id, file) {
321
- return (await hostBridge()).conversations.upload(id, file);
364
+ return (await hostBridge("conversations.upload")).conversations.upload(id, file);
322
365
  },
323
366
  async readFile(id, path) {
324
- return (await hostBridge()).conversations.readFile(id, path);
367
+ return (await hostBridge("conversations.readFile")).conversations.readFile(id, path);
325
368
  },
326
369
  },
327
370
  users: {
328
371
  async list() {
329
- return (await hostBridge()).users.list();
372
+ return (await hostBridge("users.list")).users.list();
330
373
  },
331
374
  async get(user) {
332
- return (await hostBridge()).users.get(user);
375
+ return (await hostBridge("users.get")).users.get(user);
333
376
  },
334
377
  },
335
378
  accounts: {
336
379
  async lookup(provider, subject) {
337
- return (await hostBridge()).accounts.lookup(provider, subject);
380
+ return (await hostBridge("accounts.lookup")).accounts.lookup(provider, subject);
338
381
  },
339
382
  async link(provider, subject, user, via) {
340
- return (await hostBridge()).accounts.link(provider, subject, user, via);
383
+ return (await hostBridge("accounts.link")).accounts.link(provider, subject, user, via);
341
384
  },
342
385
  async unlink(provider, subject) {
343
- return (await hostBridge()).accounts.unlink(provider, subject);
386
+ return (await hostBridge("accounts.unlink")).accounts.unlink(provider, subject);
344
387
  },
345
388
  async listForUser(user) {
346
- return (await hostBridge()).accounts.listForUser(user);
389
+ return (await hostBridge("accounts.listForUser")).accounts.listForUser(user);
347
390
  },
348
391
  },
349
392
  access: {
350
393
  async accessOf(principal, resource) {
351
- return (await hostBridge()).access.accessOf(principal, resource);
394
+ return (await hostBridge("access.accessOf")).access.accessOf(principal, resource);
352
395
  },
353
396
  },
354
397
  identity: {
355
398
  async actingUser(conversationId) {
356
- return (await hostBridge()).identity.actingUser(conversationId);
399
+ return (await hostBridge("identity.actingUser")).identity.actingUser(conversationId);
357
400
  },
358
401
  },
402
+ // Curator P6 (A9): the boot-built curation host through the bridge —
403
+ // the lazy member discipline (a factory-body call waits for the
404
+ // bridge; a post-bind call resolves at once). Every member of the
405
+ // curation surface is a function, so a Proxy that forwards each
406
+ // property read (and call) to the RESOLVED bridge host implements
407
+ // the whole surface with the same wait semantics as the async
408
+ // delegations above. Members that return SYNCHRONOUS store objects
409
+ // (`skills(user)`, `overlays(user)` — store handles whose own methods
410
+ // are async) must come back UNAWAITED: an `async` wrapper would
411
+ // return a Promise of the store, and the caller's sync `.list()` —
412
+ // the shape the view and the targets use — would read undefined
413
+ // (D1's e2e found the view rendering 500 on exactly this). The
414
+ // settled host is memoized in the HOST closure (not the getter —
415
+ // every `host.curation` read returns a FRESH proxy, and a per-proxy
416
+ // memo would reset on each read, putting the async forwarder back
417
+ // in front of sync members on the second read).
418
+ get curation() {
419
+ if (curationHost !== undefined)
420
+ return curationHost;
421
+ const pending = hostBridge("curation").then((bridge) => {
422
+ curationHost = bridge.curation;
423
+ return bridge.curation;
424
+ });
425
+ // SAFETY: the Proxy target satisfies the structural member shapes
426
+ // because every forwarded call is an `apply` trap on the real host's
427
+ // own member; the cast only bridges the contract's named members.
428
+ return new Proxy({}, {
429
+ get(_target, property) {
430
+ // The settled-bridge fast path: the member itself, bound — a
431
+ // synchronous member (`skills`) returns its store synchronously,
432
+ // an async one keeps returning its promise. The property read is
433
+ // STILL a function either way (the surface has no data members),
434
+ // so callers observe the contract shape unchanged.
435
+ if (curationHost !== undefined) {
436
+ const member = curationHost[property];
437
+ if (typeof member !== "function") {
438
+ throw new Error(`curation: member ${String(property)} is not callable`);
439
+ }
440
+ return member.bind(curationHost);
441
+ }
442
+ // The pre-bind window: every member becomes a forwarder that
443
+ // waits for the bridge (the factory-body discipline). The
444
+ // surface's SYNC store members — `skills(user)` and
445
+ // `overlays(user)` return store handles whose own methods are
446
+ // async (the shape the view and the steps call: `.list()` with
447
+ // no await on the store itself) — must come back as a STORE,
448
+ // not a promise of one (the eval's idle pass hit exactly this:
449
+ // the archive step read `.list` off a promise). A lazy store
450
+ // whose methods each await the bridge preserves the
451
+ // sync-return contract while keeping the wait: the caller
452
+ // gets the store immediately and its first method call
453
+ // resolves once the bridge lands (or rejects at the bind
454
+ // deadline through the shared pending promise).
455
+ if (property === "skills" || property === "overlays") {
456
+ return (user) => syncStoreForwarder(pending, property, user);
457
+ }
458
+ return async (...args) => {
459
+ const resolvedHost = await pending;
460
+ // SAFETY: the property is one of the contract's own function
461
+ // members (the surface has no data members); the bound call is
462
+ // the real host's, typed at the call site.
463
+ const member = resolvedHost[property];
464
+ if (typeof member !== "function") {
465
+ throw new Error(`curation: member ${String(property)} is not callable`);
466
+ }
467
+ return member.apply(resolvedHost, [...args]);
468
+ };
469
+ },
470
+ });
471
+ },
359
472
  // Gateway C4b: straight delegation (the bridge owns both bodies).
360
473
  models: {
361
474
  async list() {
362
- return (await hostBridge()).models.list();
475
+ return (await hostBridge("models.list")).models.list();
363
476
  },
364
477
  },
365
478
  commands: {
366
479
  async list() {
367
- return (await hostBridge()).commands.list();
480
+ return (await hostBridge("commands.list")).commands.list();
368
481
  },
369
482
  async run(conversationId, name, args, opts) {
370
- return (await hostBridge()).commands.run(conversationId, name, args, opts);
483
+ return (await hostBridge("commands.run")).commands.run(conversationId, name, args, opts);
371
484
  },
372
485
  },
373
486
  // The surfaces seam (UI-blocks U3 Task 4): `changed` awaits the
@@ -385,7 +498,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
385
498
  // production (nothing sets it).
386
499
  globalThis.__sigrunSurfaceChanged?.(host.name);
387
500
  };
388
- void hostBridge()
501
+ void hostBridge("surfaces.changed")
389
502
  .then((bridge) => {
390
503
  ran();
391
504
  bridge.surfaces.changed(identity.name, id, key, update);
@@ -368,8 +368,8 @@ function validate(value) {
368
368
  if (builtins === undefined) {
369
369
  throw new SettingsValidationError("builtinExtensions", "must be an object");
370
370
  }
371
- checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch", "gateway", "oauth"]);
372
- for (const key of ["codemode", "mcp", "toolSearch", "gateway", "oauth"]) {
371
+ checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch", "gateway", "oauth", "curator"]);
372
+ for (const key of ["codemode", "mcp", "toolSearch", "gateway", "oauth", "curator"]) {
373
373
  if (builtins[key] !== undefined && typeof builtins[key] !== "boolean") {
374
374
  throw new SettingsValidationError(`builtinExtensions.${key}`, "must be a boolean");
375
375
  }
@@ -29,6 +29,7 @@
29
29
  import { Type } from "@earendil-works/pi-ai";
30
30
  import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-durable/tools";
31
31
  import { defineExtension, defineTool } from "@earendil-works/pi-durable";
32
+ import { SessionMetaDoc } from "../server/session-meta.js";
32
33
  // ---------------------------------------------------------------------------
33
34
  // Coding tools (read/write/edit/bash)
34
35
  // ---------------------------------------------------------------------------
@@ -77,11 +78,80 @@ export const imageReadTool = defineTool({
77
78
  return { output: [image] };
78
79
  },
79
80
  });
81
+ /**
82
+ * Wrap the read tool with learned-root usage counting. The wrapper
83
+ * resolves the conversation owner through the api's document reader
84
+ * (the SessionMeta doc — the A3 pattern), and the read's absolute path
85
+ * against the owner's learned root; a path inside it (a SKILL.md or a
86
+ * support file) bumps `read` for the owning skill. The wrapped result
87
+ * passes through unchanged.
88
+ */
89
+ export function countReadUsage(seam, base = imageReadTool) {
90
+ return {
91
+ ...base,
92
+ execute: async (args, api, context) => {
93
+ const result = await base.execute(args, api, context);
94
+ void countLearnedRead(seam, args.path, api, context);
95
+ return result;
96
+ },
97
+ };
98
+ }
99
+ /** Bump `read` for a learned-root path, when it lands inside one. */
100
+ async function countLearnedRead(seam, path, api, context) {
101
+ try {
102
+ const meta = await api.snapshot(SessionMetaDoc, api.conversationId, context);
103
+ const owner = meta?.owner;
104
+ const counters = seam.countersOf(owner);
105
+ const root = seam.learnedRootOf(owner);
106
+ if (counters === undefined || root === undefined || api.env === undefined)
107
+ return;
108
+ const resolved = await api.env.absolutePath(path, context);
109
+ if (!resolved.ok)
110
+ return;
111
+ const skill = learnedSkillOf(resolved.value, root);
112
+ if (skill !== undefined) {
113
+ counters.bump(skill, "read");
114
+ void counters.flush();
115
+ }
116
+ }
117
+ catch {
118
+ // Counting is advisory: no read ever fails because usage telemetry
119
+ // could not resolve the owner or the path.
120
+ }
121
+ }
122
+ /** The `skills/<name>/…` segment of a path inside a learned root; `undefined` outside. */
123
+ function learnedSkillOf(absolute, root) {
124
+ if (absolute === root || !absolute.startsWith(`${root}/`))
125
+ return undefined;
126
+ const rest = absolute.slice(root.length + 1).split("/");
127
+ const name = rest[0];
128
+ if (name === undefined || name === "")
129
+ return undefined;
130
+ return name;
131
+ }
80
132
  /** `read`, `write`, `edit`, `bash` — core-installed for every conversation. */
81
133
  export const codingToolsExtension = defineExtension({
82
134
  name: "sigrun.coding-tools",
83
135
  tools: [imageReadTool, createWriteTool(), createEditTool(), createBashTool()],
84
136
  });
137
+ /**
138
+ * The coding tools with learned-root usage counting (curator A6, P4):
139
+ * with a {@link ReadUsageSeam} the `read` tool is wrapped so reads
140
+ * inside the conversation owner's learned root bump the owner's
141
+ * counters. Without one, the plain surface — the boot default until
142
+ * the curation wiring hands both seam halves.
143
+ */
144
+ export function createCodingToolsExtension(options) {
145
+ return defineExtension({
146
+ name: "sigrun.coding-tools",
147
+ tools: [
148
+ options === undefined ? imageReadTool : countReadUsage(options),
149
+ createWriteTool(),
150
+ createEditTool(),
151
+ createBashTool(),
152
+ ],
153
+ });
154
+ }
85
155
  // ---------------------------------------------------------------------------
86
156
  // Search tools (find/grep/ls) — pure TS over ExecutionEnv
87
157
  // ---------------------------------------------------------------------------
package/dist/jobs/fire.js CHANGED
@@ -39,6 +39,7 @@ import { parseModelSpec } from "../config.js";
39
39
  import { expandInput } from "../commands/expand.js";
40
40
  import { SessionMetaDoc } from "../server/session-meta.js";
41
41
  import { noteThen } from "../server/turns/submit-turn.js";
42
+ import { recordAuthor } from "../curation/attribution.js";
42
43
  import { JobRunDoc, patchJob, readJob } from "./docs.js";
43
44
  /**
44
45
  * Fire one job under `fireKey`. `fireAt` is the epoch ms the key pins
@@ -122,7 +123,9 @@ async function jobPromptText(deps, conversationId, prompt, context) {
122
123
  if (conversation === undefined)
123
124
  return prompt;
124
125
  const env = await conversationEnv(deps, conversation);
125
- const expansion = await expandInput([{ type: "text", text: prompt }], deps.commandExpansion, env, context);
126
+ // Curator A3: the learned root is the conversation owner's.
127
+ const meta = await deps.harness.snapshot(SessionMetaDoc, conversationId, context);
128
+ const expansion = await expandInput([{ type: "text", text: prompt }], deps.commandExpansion, env, context, meta?.owner);
126
129
  const part = expansion.parts[0];
127
130
  return part !== undefined && part.type === "text" ? part.text : prompt;
128
131
  }
@@ -175,11 +178,21 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
175
178
  }, context);
176
179
  }
177
180
  const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
178
- await noteThen(deps.harness, conversation, () => conversation.submit({
179
- type: "input",
180
- content: [{ type: "text", text: prompt }],
181
- requestId: `job:${fireKey}`,
182
- }, context), context);
181
+ await noteThen(deps.harness, conversation,
182
+ // Attribution (curator P1): the job's input is authored by the
183
+ // job's owner — `byRequest` before the admission, `bySubmission`
184
+ // after, exactly like submitOp (an ownerless job records nothing).
185
+ async () => {
186
+ const requestId = `job:${fireKey}`;
187
+ await recordAuthor(conversation, { requestId }, record.owner, context);
188
+ const submission = await conversation.submit({
189
+ type: "input",
190
+ content: [{ type: "text", text: prompt }],
191
+ requestId,
192
+ }, context);
193
+ await recordAuthor(conversation, { submissionId: submission.id }, record.owner, context);
194
+ return submission;
195
+ }, context);
183
196
  return { outcome: "running", conversationId };
184
197
  }
185
198
  // conversation: "new" — the run record + conversation in ONE commit.
@@ -237,11 +250,20 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
237
250
  return { outcome: "blocked" };
238
251
  }
239
252
  const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
240
- await noteThen(deps.harness, conversation, () => conversation.submit({
241
- type: "input",
242
- content: [{ type: "text", text: prompt }],
243
- requestId: `job:${fireKey}`,
244
- }, context), context);
253
+ await noteThen(deps.harness, conversation,
254
+ // Attribution (curator P1), the twin of the fixed-conversation site
255
+ // above: the job's owner authors the job's input.
256
+ async () => {
257
+ const requestId = `job:${fireKey}`;
258
+ await recordAuthor(conversation, { requestId }, record.owner, context);
259
+ const submission = await conversation.submit({
260
+ type: "input",
261
+ content: [{ type: "text", text: prompt }],
262
+ requestId,
263
+ }, context);
264
+ await recordAuthor(conversation, { submissionId: submission.id }, record.owner, context);
265
+ return submission;
266
+ }, context);
245
267
  return { outcome: "running", conversationId };
246
268
  }
247
269
  /** `<job name> · <YYYY-MM-DD HH:mm in the job's timezone>` (the plan's name). */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The core-side job skill-reference reader (curator P6, A9): the skill
3
+ * names a user's job PROMPTS reference — `/skill:name` tokens in every
4
+ * prompt action's text, from job records the durable scan sees (api,
5
+ * file, and extension sources alike), ENABLED AND PAUSED alike.
6
+ *
7
+ * The curator's diet step (Part B) needs this as the "job depends on
8
+ * this skill" signal: archiving a skill a paused job still references
9
+ * would break the job the moment it is re-enabled, so paused jobs count
10
+ * exactly as much as live ones.
11
+ *
12
+ * Pure read: one storage scan over the `sigrun.job` family (the same
13
+ * `scanJobs` primitive the jobs service's own reads use), filtered by
14
+ * owner, then a text scan for `/skill:name` tokens. No mutation, no
15
+ * task enumeration.
16
+ */
17
+ import { scanJobs } from "./docs.js";
18
+ /** The `/skill:name` token grammar (the expansion path's own prefix). */
19
+ const SKILL_TOKEN = /(?:^|\s)\/skill:([A-Za-z0-9][A-Za-z0-9._-]*)/g;
20
+ /**
21
+ * Every skill name the user's job prompts reference (`/skill:name`
22
+ * tokens), paused jobs included. A user with no jobs (or an unknown
23
+ * user) reads an empty set — the scan is the truth, never a directory
24
+ * check.
25
+ */
26
+ export async function jobSkillRefs(storage, user, ctx) {
27
+ const jobs = await scanJobs(storage, ctx);
28
+ const refs = new Set();
29
+ for (const { record } of jobs) {
30
+ if (record.owner !== user)
31
+ continue;
32
+ const action = record.spec.action;
33
+ if (action.kind !== "prompt")
34
+ continue;
35
+ for (const match of action.prompt.matchAll(SKILL_TOKEN)) {
36
+ refs.add(match[1]);
37
+ }
38
+ }
39
+ return refs;
40
+ }
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, realpathSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
5
5
  import { agentEnv } from "../harness/agent-env.js";
6
+ import { learnedSkillsDir } from "../curation/paths.js";
6
7
  import { revalidate, validateGrantPath } from "./grants.js";
7
8
  function canonical(path) {
8
9
  try {
@@ -179,6 +180,16 @@ export function buildUserPolicy(record, ctx) {
179
180
  continue;
180
181
  mkdirSync(path, { recursive: true, mode: 0o700 });
181
182
  }
183
+ // Curator A3: the owner's learned skills root exists and is granted
184
+ // read-only below. Created here (the designed empty case is an absent
185
+ // dir), validated like every other per-user path before entering
186
+ // policy — never another user's root.
187
+ const learned = learnedSkillsDir(userArea);
188
+ mkdirSync(learned, { recursive: true, mode: 0o700 });
189
+ const learnedValidation = validateUserPath(learned, record, ctx.managed);
190
+ if (!learnedValidation.ok) {
191
+ return invalidReason(learned, learnedValidation.reason);
192
+ }
182
193
  const workspaceValidation = validateUserPath(requestedWorkspace, record, ctx.managed);
183
194
  if (!workspaceValidation.ok) {
184
195
  return invalidReason(requestedWorkspace, workspaceValidation.reason);
@@ -243,6 +254,9 @@ export function buildUserPolicy(record, ctx) {
243
254
  ...moduleTrees.paths,
244
255
  defaultPackageConfig(ctx.managed.coreRoot),
245
256
  ...grants.flatMap((grant) => (grant.access === "ro" ? [grant.path] : [])),
257
+ // Curator A3: read access to the owner's own learned skills (support
258
+ // files are read with ordinary file tools); writes go through P3.
259
+ learnedValidation.realpath,
246
260
  ]);
247
261
  const env = agentEnv();
248
262
  delete env.SSH_AUTH_SOCK;
@@ -13,7 +13,9 @@
13
13
  * The surface's unnoted change records fold into `content` first and
14
14
  * `notedThrough` advances BEFORE the §8.3 note step runs — so the turn
15
15
  * sends no separate note for this surface, while changes on OTHER
16
- * surfaces still note. `requestId` makes a double click one turn.
16
+ * surfaces still note. `requestId` makes a double click one turn. The
17
+ * acting `user` rides through to `submitOp` as the answer's author
18
+ * (curator P1).
17
19
  */
18
20
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
21
  import { toText } from "@sigrun/shared/blocks";
@@ -30,14 +32,15 @@ import { BlocksStateDoc } from "../../harness/blocks/state.js";
30
32
  * `skipNoteFor` (this surface notes no further this turn) and the
31
33
  * answer entry. The route owns access gating and actionId dedup; this
32
34
  * caller runs only for a located, write-accessible block whose handler
33
- * starts a turn.
35
+ * starts a turn. `user` is the acting user (curator P1): recorded as the
36
+ * answer input's author, `undefined` for an unidentified caller.
34
37
  *
35
38
  * The fold and the `notedThrough` advance run in ONE commit BEFORE the
36
39
  * note step (§8.4: "The surface's unnoted change records are folded into
37
40
  * `content` first, and `notedThrough` advances, before the §8.3 note
38
41
  * step runs"), so the answer turn never double-reports its surface.
39
42
  */
40
- export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId) {
43
+ export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId, user) {
41
44
  const { opened } = ctx;
42
45
  const block = await readChatBlock(opened.harness, conversationId, entryId);
43
46
  if (block === undefined) {
@@ -80,6 +83,9 @@ export async function answerBlock(ctx, conversationId, entryId, action, payload,
80
83
  }, BACKGROUND_CONTEXT);
81
84
  await submitOp(ctx, conversation, folded.content, {
82
85
  ...(actionId === undefined ? {} : { requestId: `blocks:${actionId}` }),
86
+ // Attribution (curator P1): the answering user authors the answer's
87
+ // input turn.
88
+ ...(user === undefined ? {} : { user }),
83
89
  skipNoteFor: entryId,
84
90
  entry: {
85
91
  kind: BlocksAnswerEntry.kind,