@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
@@ -30,16 +30,41 @@ import { openIdentityStores, ensureSetupCode, } from "./identity/index.js";
30
30
  import { MEMORY_SECTION_KEY, noopMemoryExtension } from "./memory-default.js";
31
31
  import { createSeamHost, } from "./seam-host.js";
32
32
  import { contextFilesExtension } from "./sections/context-files.js";
33
+ import { createJobStateExtension } from "./sections/job-state.js";
33
34
  import { modelGuidanceExtension } from "./sections/model-guidance.js";
34
- import { createSkillsExtension } from "../skills/index.js";
35
+ import { createSkillsExtension, scanFsRoot } from "../skills/index.js";
35
36
  import { SLASH_COMMANDS } from "../server/commands.js";
36
37
  import { packagePromptRootsOf } from "../commands/templates.js";
37
38
  import { blocksToolsExtension } from "./blocks/tools.js";
38
- import { codingToolsExtension, searchToolsExtension } from "./tools.js";
39
+ import { createCodingToolsExtension, searchToolsExtension } from "./tools.js";
39
40
  import { collectProviders, createProviderMergeSink, } from "./providers/index.js";
40
41
  import { EventHub } from "../server/events/hub.js";
41
- import { providerAuthStatus } from "../server/helpers.js";
42
+ import { providerAuthStatus, storeAgentChange } from "../server/helpers.js";
42
43
  import { bootRefresh, DEFAULT_REFRESH_TIMEOUT_MS, refreshProvider, } from "./providers/refresh.js";
44
+ /**
45
+ * The default per-request stream deadline (curator-eval hardening): the
46
+ * OpenAI SDK waits up to 10 minutes for response HEADERS by default, and
47
+ * a request that never reaches the provider never gets headers — the
48
+ * generation task wedges in phase=request. Five minutes covers long
49
+ * first tokens on slow providers (observed 40-60s on the eval models;
50
+ * reasoning models can run far longer once streaming) because the SDK's
51
+ * timer clears at headers arrival, not stream end.
52
+ */
53
+ export const DEFAULT_STREAM_TIMEOUT_MS = 300_000;
54
+ /**
55
+ * The boot `stream` getter's merge, factored out for the harness-open
56
+ * test: caller seam over file over the default `timeoutMs`, field-wise.
57
+ * Returns `undefined` when nothing is set (the honest empty shape).
58
+ */
59
+ export function mergeStreamSettings(caller, file) {
60
+ const merged = {
61
+ ...(caller ?? file),
62
+ ...(caller === undefined && file?.timeoutMs === undefined
63
+ ? { timeoutMs: DEFAULT_STREAM_TIMEOUT_MS }
64
+ : {}),
65
+ };
66
+ return Object.keys(merged).length > 0 ? merged : undefined;
67
+ }
43
68
  import { startProviderRefresh } from "./providers/scheduler.js";
44
69
  import { fileModelsStore } from "./providers/models-store.js";
45
70
  import { packagesDirOf, selfPackageRoot } from "./packages.js";
@@ -54,7 +79,16 @@ import { IsolationState } from "../sandbox/isolation.js";
54
79
  import { probeOsUsers } from "../sandbox/availability.js";
55
80
  import { createAdmissionGuard } from "./identity/users.js";
56
81
  import { sweepAccountLinks } from "./identity/link-sweep.js";
82
+ import { effectiveOwner as resolveEffectiveOwner } from "./identity/primary-admin.js";
57
83
  import { SessionMetaDoc } from "../server/session-meta.js";
84
+ import { learnedSkillsDir } from "../curation/paths.js";
85
+ import { SkillStore } from "../curation/store.js";
86
+ import { OverlayStore } from "../curation/overlays.js";
87
+ import { UsageCounters } from "../curation/usage.js";
88
+ import { buildCurationRegistry } from "../curation/targets.js";
89
+ import { createCurationHost } from "../curation/host.js";
90
+ import { createCurationToolFeed, wrapExtensionCurationTools, } from "../curation/wrap.js";
91
+ import { createCurationGateExtension, CURATION_GATE_EXTENSION } from "../curation/gate.js";
58
92
  import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
59
93
  import { resolveBuiltins, } from "./builtins.js";
60
94
  import { createRegistry } from "./registry.js";
@@ -88,6 +122,7 @@ export const RESERVED_CORE_SECTION_OWNERS = {
88
122
  skills: "sigrun.skills",
89
123
  "tool-guidance": "sigrun.model-guidance",
90
124
  "execution-guidance": "sigrun.model-guidance",
125
+ "job-state": "sigrun.job-state",
91
126
  };
92
127
  /**
93
128
  * Open (or reopen) the durable harness at `config.storagePath`.
@@ -329,6 +364,10 @@ export async function openHarness(config, options = {}) {
329
364
  // reads it at RENDER time, long after the assignment — the skills cap
330
365
  // is a live setting (`skills.sectionMaxBytes`, default 4KB).
331
366
  let skillsSettings;
367
+ // The job-state section resolves its services only when a job run renders;
368
+ // both are constructed after the core registry installs.
369
+ let workspaceService;
370
+ let jobs;
332
371
  // Context-file sections (SOUL.md persona, AGENTS.md instructions), the
333
372
  // skills convention, and the core tool surface (plan Task 1: coding
334
373
  // tools read/write/edit/bash + search tools find/grep/ls) are all
@@ -346,9 +385,106 @@ export async function openHarness(config, options = {}) {
346
385
  // ones, which `listSkills` includes by design).
347
386
  const skillsOptions = {
348
387
  userDir: join(roots.configDir, "skills"),
388
+ // Curator A9 (P6): the canonical workspace ROOT for overlay keys —
389
+ // the configured default workspace (a conversation with a
390
+ // sub-cwd still matches the root-keyed overlay; the accessor
391
+ // stays live for the config's sake).
392
+ workspaceRoot: () => config.workspace.path,
393
+ // Curator A3 (P2): the conversation owner's learned root —
394
+ // `<dataDir>/users/<owner>/skills`, undefined for ownerless
395
+ // conversations. Discovery resolves the owner per render.
396
+ learnedRoot: (owner) => owner === undefined
397
+ ? undefined
398
+ : learnedSkillsDir(managedPaths.userAreas(owner)),
349
399
  packageRoots: () => collectedSkillRoots,
350
400
  sectionMaxBytes: () => skillsSettings?.read().skills?.sectionMaxBytes ?? 4096,
351
401
  };
402
+ // Curator A6 (P3/P4): the per-user curation wiring — one skill store,
403
+ // one overlay store and one usage counter set per EXISTING user,
404
+ // created lazily on first use and cached. `userExists` is the LIVE
405
+ // directory read (a removed user's factories stop resolving), and
406
+ // `nameTakenElsewhere` scans the host-side roots the harness owns
407
+ // (the user dir and the collected package roots; a conversation's
408
+ // workspace can only be checked through its env, which the store
409
+ // cannot see — the create-refusal there is the A4 store's own).
410
+ const skillStores = new Map();
411
+ const overlayStores = new Map();
412
+ const usageCounters = new Map();
413
+ const curationOf = (user) => {
414
+ if (user === undefined)
415
+ return undefined;
416
+ // Curator A10: cache-FIRST resolution — a store set captured before
417
+ // a user's deletion stays resolvable, so its own `userExists` gate
418
+ // fails every later op with "user removed" (the A4/A5 fail-closed
419
+ // property: an in-flight run's late write never recreates the
420
+ // roots). A never-seen user still needs the live directory read.
421
+ if (skillStores.get(user) === undefined &&
422
+ identity.users.get(user) === undefined) {
423
+ return undefined;
424
+ }
425
+ const userArea = managedPaths.userAreas(user);
426
+ let store = skillStores.get(user);
427
+ if (store === undefined) {
428
+ store = new SkillStore({
429
+ userArea,
430
+ user,
431
+ now: () => clockNow(),
432
+ nameTakenElsewhere: async (name) => {
433
+ for (const skill of scanFsRoot(join(roots.configDir, "skills"), "user dir")) {
434
+ if (skill.name === name)
435
+ return "user dir";
436
+ }
437
+ for (const root of collectedSkillRoots) {
438
+ if (scanFsRoot(root.dir, "package").some((skill) => skill.name === name))
439
+ return root.extension;
440
+ }
441
+ return undefined;
442
+ },
443
+ userExists: () => identity.users.get(user) !== undefined,
444
+ });
445
+ skillStores.set(user, store);
446
+ }
447
+ let overlays = overlayStores.get(user);
448
+ if (overlays === undefined) {
449
+ overlays = new OverlayStore({
450
+ userArea,
451
+ user,
452
+ userExists: () => identity.users.get(user) !== undefined,
453
+ feed: store,
454
+ });
455
+ overlayStores.set(user, overlays);
456
+ }
457
+ let usage = usageCounters.get(user);
458
+ if (usage === undefined) {
459
+ usage = new UsageCounters({
460
+ userArea,
461
+ user,
462
+ now: () => clockNow(),
463
+ // The A4 gate, but silent: the cached counters outlive the user
464
+ // (cache-first resolution), and telemetry is void-and-forget —
465
+ // a removed user's flush drops instead of recreating the
466
+ // deleted curation directory (deletion's fail-closed claim).
467
+ userExists: () => identity.users.get(user) !== undefined,
468
+ });
469
+ usageCounters.set(user, usage);
470
+ }
471
+ return { store, overlays, usage };
472
+ };
473
+ const curationSkillsOptions = {
474
+ ...skillsOptions,
475
+ skillStore: (user) => curationOf(user)?.store,
476
+ overlays: (owner) => curationOf(owner)?.overlays,
477
+ usage: (owner) => curationOf(owner)?.usage,
478
+ };
479
+ // Curator A6 (P4): the read tool counts learned-root reads through the
480
+ // same per-user wiring (load_skill and /skill: count through the
481
+ // skills options above).
482
+ const codingWithUsage = createCodingToolsExtension({
483
+ countersOf: (owner) => curationOf(owner)?.usage,
484
+ learnedRootOf: (owner) => owner === undefined
485
+ ? undefined
486
+ : learnedSkillsDir(managedPaths.userAreas(owner)),
487
+ });
352
488
  const templateOptions = {
353
489
  userDir: join(roots.configDir, "prompts"),
354
490
  packageRoots: () => collectedTemplateRoots,
@@ -371,8 +507,29 @@ export async function openHarness(config, options = {}) {
371
507
  },
372
508
  };
373
509
  registry.install(contextFilesExtension);
374
- registry.install(createSkillsExtension(skillsOptions));
375
- registry.install(codingToolsExtension);
510
+ // Escalations A2: the core-owned job-state prompt section names the run's
511
+ // state folder (WorkspaceService.jobsRoot, A1); deps late-bind like
512
+ // skillsSettings — services are created further down.
513
+ registry.install(createJobStateExtension({
514
+ jobsRoot: (owner) => workspaceService?.jobsRoot(owner),
515
+ job: async (id) => await jobs?.get(id),
516
+ }));
517
+ // Curator A8 (P9): the wrap feed — the mutable set of curation tool
518
+ // names, seeded with the core set and grown by the loader as each
519
+ // extension's curationTargets validate. The skills extension's
520
+ // install below wraps through it (its OWN tools: `skill_manage`);
521
+ // the loader wraps every extension-declared target tool at its
522
+ // install. `isCurationTool` on the built registry (A7) stays the
523
+ // read-side API — the feed is only the write-side wrap oracle.
524
+ const curationFeed = createCurationToolFeed();
525
+ // Curator A6: the skills extension carries the curation wiring
526
+ // (skill_manage + usage counting), and the coding tools count
527
+ // learned-root reads — the same extension NAMES as before, so
528
+ // nothing downstream observes the swap. A8: the install WRAPS the
529
+ // curation tools (skill_manage's registration carries the hint
530
+ // interception; load_skill and the section are untouched).
531
+ registry.install(wrapExtensionCurationTools(createSkillsExtension(curationSkillsOptions), curationFeed));
532
+ registry.install(codingWithUsage);
376
533
  registry.install(searchToolsExtension);
377
534
  // The chat-blocks tools (UI-blocks U5 Task 1, spec §8.1): every agent
378
535
  // can show and update interactive block surfaces in chat — the same
@@ -466,8 +623,11 @@ export async function openHarness(config, options = {}) {
466
623
  const sharedStores = sharedExtensionStores(roots, env, identity.users);
467
624
  const seamHosts = new Map();
468
625
  const seamKinds = new Map();
469
- const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
626
+ const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, curationBindings, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
470
627
  packagesDir,
628
+ // Curator A8 (P9): the wrap feed — the loader grows it with each
629
+ // validated target tool and wraps those tools at their install.
630
+ curationFeed,
471
631
  hostContextFactory: (extensionName, kinds, priorHost) => {
472
632
  // Two-phase, revised for the provisional identity (provider
473
633
  // plugins Task 9 P1): the loader's PRE-import call passes the
@@ -505,6 +665,11 @@ export async function openHarness(config, options = {}) {
505
665
  },
506
666
  });
507
667
  perUserSlugs.push(...loadedPerUserSlugs);
668
+ // The curation-target registry (curator P7): the loader's validated,
669
+ // slug-attributed bindings folded into the ONE registry — the A8
670
+ // wrapping feed (isCurationTool) and the A9 host.curation surface
671
+ // read it. An empty fold is the no-declarer deploy.
672
+ const curation = buildCurationRegistry(curationBindings);
508
673
  for (const binding of userServiceBindings) {
509
674
  userServices.register(binding.extension, binding.services, binding.packageDir);
510
675
  }
@@ -614,6 +779,9 @@ export async function openHarness(config, options = {}) {
614
779
  // deps object is shared with `fireJob`'s run-now and the JobsService
615
780
  // below (one clock, one tz, one models store).
616
781
  const jobRuntimeDeps = {
782
+ // Resolve declared-job owners against the live directory at each use
783
+ // (§6.5); no cache and no rewrite of the source's stored record.
784
+ effectiveOwner: (owner) => resolveEffectiveOwner(identity.users.list(), owner),
617
785
  // SAFETY: harness + storage are assigned right after `Harness.open`
618
786
  // returns below (the open needs the task registered, so the deps
619
787
  // object is created first with placeholders). Every read — task
@@ -640,6 +808,11 @@ export async function openHarness(config, options = {}) {
640
808
  name: "sigrun.jobs",
641
809
  tasks: [createScheduleTask(jobRuntimeDeps)],
642
810
  });
811
+ // Curator A8 (P6 gate): the hooks-only curator-run gate — installed
812
+ // in the registry but NEVER in the host default selection (the
813
+ // settings getter below filters it like a per-user entry): only A9's
814
+ // producer selects it, in the internal run conversations it creates.
815
+ registry.install(createCurationGateExtension());
643
816
  // The host event hub (W3 Task 4, spec §7.4): ONE per opened harness.
644
817
  // The event bus's commit-derived events subscribe below (after the bus
645
818
  // opens); the provider refresh paths observe readiness here — each
@@ -756,7 +929,7 @@ export async function openHarness(config, options = {}) {
756
929
  stopProviderRefresh();
757
930
  };
758
931
  // Every tool/file/upload/job env resolves through this one decision seam.
759
- const workspaceService = createWorkspaceService({
932
+ workspaceService = createWorkspaceService({
760
933
  isolation: isolationState,
761
934
  osUsers,
762
935
  pool: sandboxPool,
@@ -828,14 +1001,26 @@ export async function openHarness(config, options = {}) {
828
1001
  return callerSettings?.retry ?? fileSettings.retry;
829
1002
  },
830
1003
  get stream() {
831
- return callerSettings?.stream ?? fileSettings.stream;
1004
+ // The stream-request default (curator-eval hardening): a
1005
+ // model request with no explicit timeout can wedge forever —
1006
+ // the OpenAI SDK's `timeout` is a headers-arrival deadline and
1007
+ // defaults to 10 minutes, but a request that never reaches the
1008
+ // provider (undici socket-pool class) never gets headers to
1009
+ // time out against, so pi-durable's generation task sits in
1010
+ // phase=request indefinitely. `mergeStreamSettings` bounds
1011
+ // every request with a default `timeoutMs`; the caller seam
1012
+ // and the file each override field-wise.
1013
+ return mergeStreamSettings(callerSettings?.stream, fileSettings.stream);
832
1014
  },
833
1015
  get extensions() {
834
1016
  // Per-user entries are core-computed from SessionMeta.owner,
835
1017
  // never part of a host default — including an operator-supplied
836
- // caller list (controller ruling: filter BOTH sources).
1018
+ // caller list (controller ruling: filter BOTH sources). The
1019
+ // curator-run gate (A8) is filtered the same way: it is selected
1020
+ // ONLY in the internal run conversations A9's producer creates.
837
1021
  const selected = callerSettings?.extensions ?? registry.snapshot().installed();
838
- return selected.filter((entry) => !isUserEntryName(entry.name));
1022
+ return selected.filter((entry) => !isUserEntryName(entry.name) &&
1023
+ entry.name !== CURATION_GATE_EXTENSION);
839
1024
  },
840
1025
  toolExecution: callerSettings?.toolExecution,
841
1026
  compaction: {
@@ -920,6 +1105,36 @@ export async function openHarness(config, options = {}) {
920
1105
  // order). The bus's close stops delivery; the unsubscribe is unused.
921
1106
  const now = clockNow;
922
1107
  wireActivity(eventBus, harness, now);
1108
+ // Curator A9 (P6): the ONE curation host — the `host.curation`
1109
+ // surface over the per-user stores (curationOf), the curation
1110
+ // registry, the jobs records and the run driver. The server's host
1111
+ // bridge and every seam host expose the SAME instance (the bridge
1112
+ // carries it; the seam hosts delegate). The digest producer's
1113
+ // default agent is the create op's own store-default change (what
1114
+ // every server-created conversation starts from): the structural
1115
+ // `{settings, registry, providerEnv}` subset `storeAgentChange`
1116
+ // reads, closed over the LIVE references so a settings PATCH
1117
+ // reaches later digests without a restart.
1118
+ // SAFETY: the cast — `storeAgentChange` reads exactly these three
1119
+ // members (settings.read, registry.snapshot, providerEnv) and
1120
+ // nothing else of OpenedHarness; the live closure satisfies them.
1121
+ const curationHost = createCurationHost({
1122
+ harness,
1123
+ registry,
1124
+ storage,
1125
+ curation,
1126
+ defaultModel,
1127
+ stores: curationOf,
1128
+ skillsOptions: {
1129
+ userDir: join(roots.configDir, "skills"),
1130
+ packageRoots: () => collectedSkillRoots,
1131
+ },
1132
+ defaultAgent: () => storeAgentChange({
1133
+ settings: { read: () => settingsStore.read() },
1134
+ registry,
1135
+ providerEnv: effectiveEnv,
1136
+ }, defaultModel),
1137
+ });
923
1138
  const handlersByName = new Map();
924
1139
  for (const binding of runSettledBindings) {
925
1140
  handlersByName.set(binding.extension.name, {
@@ -1007,6 +1222,7 @@ export async function openHarness(config, options = {}) {
1007
1222
  // (an extension's declaration is its boot-time declaration).
1008
1223
  const fileJobs = () => settingsStore.read().jobs ?? [];
1009
1224
  const jobsSources = () => ({ file: fileJobs(), extensions: jobBindings });
1225
+ const jobEffectiveOwner = (owner) => resolveEffectiveOwner(identity.users.list(), owner);
1010
1226
  // The root conversation — the schedule tasks' owner (spec §8.1).
1011
1227
  // `Harness.root` is idempotent (creates with `agent` on first call,
1012
1228
  // returns the reserved root thereafter), so the server's own
@@ -1035,7 +1251,7 @@ export async function openHarness(config, options = {}) {
1035
1251
  // SSE stream computes `accessOf` per principal and adds it. A
1036
1252
  // future in-process hub subscriber must never read a fabricated
1037
1253
  // access off the hub's copy.
1038
- const { access: _dropped, ...row } = jobWireOf(jobId, record);
1254
+ const { access: _dropped, ...row } = jobWireOf(jobId, record, jobEffectiveOwner(record.owner));
1039
1255
  events.publish({
1040
1256
  type: "job.changed",
1041
1257
  job: row,
@@ -1051,7 +1267,9 @@ export async function openHarness(config, options = {}) {
1051
1267
  id: jobId,
1052
1268
  name: newest.spec.name,
1053
1269
  source: newest.source,
1054
- ...(newest.owner === undefined ? {} : { owner: newest.owner }),
1270
+ ...(jobEffectiveOwner(newest.owner) === undefined
1271
+ ? {}
1272
+ : { owner: jobEffectiveOwner(newest.owner) }),
1055
1273
  ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
1056
1274
  schedule: newest.spec.schedule,
1057
1275
  action: newest.spec.action,
@@ -1087,12 +1305,14 @@ export async function openHarness(config, options = {}) {
1087
1305
  jobRunTotals.set(outcome, (jobRunTotals.get(outcome) ?? 0) + 1);
1088
1306
  }
1089
1307
  });
1090
- const jobs = createJobsService({
1308
+ jobs = createJobsService({
1091
1309
  harness,
1092
1310
  storage,
1093
1311
  runtime: jobRuntimeDeps,
1312
+ effectiveOwner: jobEffectiveOwner,
1094
1313
  rootId: rootConversationId,
1095
1314
  sources: jobsSources,
1315
+ jobsRoot: (owner) => workspaceService?.jobsRoot(owner),
1096
1316
  settings: () => settingsStore.read(),
1097
1317
  onChange: (jobId) => {
1098
1318
  void publishJobChanged(jobId);
@@ -1113,6 +1333,7 @@ export async function openHarness(config, options = {}) {
1113
1333
  runtime: jobRuntimeDeps,
1114
1334
  rootId: rootConversationId,
1115
1335
  sources: jobsSources,
1336
+ jobsRoot: (owner) => workspaceService?.jobsRoot(owner),
1116
1337
  }, BACKGROUND_CONTEXT);
1117
1338
  // The deleted-owner warning (§8.3): a declared job whose `owner` names
1118
1339
  // a user no longer in the directory runs OWNERLESS until its source is
@@ -1176,6 +1397,8 @@ export async function openHarness(config, options = {}) {
1176
1397
  channelBindings,
1177
1398
  gatewayEngine,
1178
1399
  skillRoots: extensionSkillRoots,
1400
+ curation,
1401
+ curationHost,
1179
1402
  commandExpansion,
1180
1403
  eventBus,
1181
1404
  events,
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Built-in extensions: codemode, MCP, tool search and OAuth ship WITH
3
- * core — present by default, disableable via settings `builtinExtensions`,
4
- * replaceable by a same-named configured extension.
2
+ * Built-in extensions: codemode, MCP, tool search, the gateway, OAuth and
3
+ * the curator ship WITH core — present by default, disableable via settings
4
+ * `builtinExtensions`, replaceable by a same-named configured extension.
5
+ * Every other extension is optional and loads only when configured.
5
6
  *
6
- * The first three mirror pi 1.3.0's built-in tool surface; OAuth adds
7
- * sigrun's standard OIDC/OAuth2 login providers without extra setup.
7
+ * The first three mirror pi 1.3.0's built-in tool surface; the gateway is
8
+ * the channel-adapter engine (hermes/openclaw parity: channels work out of
9
+ * the box); OAuth adds sigrun's standard OIDC/OAuth2 login providers
10
+ * without extra setup; the curator runs the per-user learning loops.
8
11
  *
9
12
  * Resolution root (Ruling 2): built-ins resolve through core's OWN
10
13
  * node_modules via `createRequire(import.meta.url)` — they are core
@@ -23,7 +26,8 @@
23
26
  * (`readPackageName` for path-shaped entries, `parsePackageSpecifier`
24
27
  * otherwise), never by string equality on the raw specifier.
25
28
  *
26
- * Disabled (Ruling 6): `builtinExtensions: {codemode?, mcp?, toolSearch?, oauth?}`
29
+ * Disabled (Ruling 6): `builtinExtensions: {codemode?, mcp?, toolSearch?,
30
+ * gateway?, oauth?, curator?}`
27
31
  * (each defaulting to `true`, restart-required). A disabled built-in is
28
32
  * absent from `/v1/extensions` entirely; one log line records the skip.
29
33
  *
@@ -38,13 +42,15 @@ import { basename, dirname, isAbsolute, resolve } from "node:path";
38
42
  import { fileURLToPath, pathToFileURL } from "node:url";
39
43
  import { parsePackageSpecifier, readPackageName } from "./packages.js";
40
44
  /**
41
- * The five built-ins, in load order (codemode before tool-search: both
45
+ * The six built-ins, in load order (codemode before tool-search: both
42
46
  * wrap pi-durable's own extensions and neither depends on the other, but
43
- * the order pins the tool-collision story should names ever overlap).
44
- * Specifiers are the published package names (Task 3) — npm keys a file:
45
- * dependency by the target's own manifest name, so the key must be the
46
- * name the packages publish under (the dev shape carries `file:` values
47
- * under these same keys until Task 8 publishes exact `0.1.0`s).
47
+ * the order pins the tool-collision story should names ever overlap;
48
+ * curator loads LAST — its loops observe every earlier built-in's
49
+ * contributions). Specifiers are the published package names (Task 3)
50
+ * — npm keys a file: dependency by the target's own manifest name, so
51
+ * the key must be the name the packages publish under (the dev shape
52
+ * carries `file:` values under these same keys until Task 8 publishes
53
+ * exact `0.1.0`s).
48
54
  */
49
55
  export const BUILTIN_EXTENSIONS = [
50
56
  { key: "codemode", specifier: "@sigrun/ext-codemode", slug: "codemode" },
@@ -52,6 +58,7 @@ export const BUILTIN_EXTENSIONS = [
52
58
  { key: "toolSearch", specifier: "@sigrun/ext-tool-search", slug: "tool-search" },
53
59
  { key: "gateway", specifier: "@sigrun/ext-gateway", slug: "gateway" },
54
60
  { key: "oauth", specifier: "@sigrun/oauth", slug: "oauth" },
61
+ { key: "curator", specifier: "@sigrun/ext-curator", slug: "curator" },
55
62
  ];
56
63
  /** The core-anchored require (built-ins resolve from core's own node_modules). */
57
64
  const coreRequire = createRequire(import.meta.url);
@@ -103,6 +103,22 @@ export class EventBus {
103
103
  }
104
104
  #unsubscribe;
105
105
  #harness;
106
+ /**
107
+ * Internal-run detection (curator P5): the SessionMeta `internal` flag,
108
+ * resolved SYNCHRONOUSLY from the commit stream — every SessionMeta
109
+ * document change warms the cache (see the pre-pass in `observe`), so
110
+ * the arms there always find the flag of the conversation the
111
+ * publication belongs to. The marker is written in the conversation's
112
+ * CREATING commit and never changes (`markInternal`), so a cached
113
+ * value never goes stale; `false` is cached too, keeping ordinary
114
+ * traffic at zero extra reads (the bus's no-blocking-work contract
115
+ * forbids a snapshot here).
116
+ */
117
+ #internal = new Map();
118
+ /** Whether `conversationId` is internal per the commit-warmed cache. */
119
+ #isInternal(conversationId) {
120
+ return this.#internal.get(conversationId) === true;
121
+ }
106
122
  #queueMax;
107
123
  #hookDeadlineMs;
108
124
  #idleThresholdMs;
@@ -663,10 +679,37 @@ export class EventBus {
663
679
  try {
664
680
  const lifecycle = [];
665
681
  const forkParents = new Map();
682
+ // Curator P5, PRE-PASS: warm the internal-flag cache off every
683
+ // SessionMeta document change BEFORE any arm below can fire —
684
+ // change order within a publication is unspecified, so the
685
+ // conversation/submission arms must never depend on the SessionMeta
686
+ // change having been seen first (an internal conversation created in
687
+ // THIS commit emits nothing, its `conversation.created` host event
688
+ // included). The plain `document` arm alone: a fork's
689
+ // `document.copy` carries no value to read (and `internal` is never
690
+ // inherited — the child's own first SessionMeta commit re-warms it).
691
+ // The marker is written in the creating commit and never changes
692
+ // (`markInternal`), so a cached value never goes stale; `false` is
693
+ // cached too, keeping ordinary traffic at zero extra reads (the
694
+ // bus's no-blocking-work contract forbids a snapshot here).
695
+ for (const change of publication.changes) {
696
+ if (change.type !== "document")
697
+ continue;
698
+ if (change.record.kind !== SessionMetaDoc.definition.kind)
699
+ continue;
700
+ if (change.conversationId === undefined)
701
+ continue;
702
+ this.#internal.set(change.conversationId, change.value?.internal === true);
703
+ }
666
704
  for (const change of publication.changes) {
667
705
  if (change.type !== "conversation")
668
706
  continue;
669
707
  const record = change.value;
708
+ // Curator P5: an internal conversation emits nothing — not the
709
+ // lifecycle events, not the host event (every owner-keyed path
710
+ // skips internal; the SSE stream's filtering is belt-and-braces).
711
+ if (this.#isInternal(record.id))
712
+ continue;
670
713
  lifecycle.push({
671
714
  type: "conversationCreated",
672
715
  conversationId: record.id,
@@ -690,6 +733,17 @@ export class EventBus {
690
733
  if (change.type !== "document" && change.type !== "document.copy") {
691
734
  continue;
692
735
  }
736
+ // Curator P5: an internal conversation emits nothing on ANY
737
+ // document arm — not the SessionMeta summary diffs, and not the
738
+ // interaction/ambient seam events either (every conversation.*
739
+ // host event and every derived lifecycle is withheld; the flag
740
+ // was warmed by the pre-pass above).
741
+ if (change.conversationId !== undefined &&
742
+ this.#isInternal(change.conversationId)) {
743
+ this.#archivedBasis.delete(change.conversationId);
744
+ this.#metaBasis.delete(change.conversationId);
745
+ continue;
746
+ }
693
747
  const kind = change.record.kind;
694
748
  if (kind !== SessionMetaDoc.definition.kind) {
695
749
  // W3 Task 4: the interaction + ambient seam doc families feed
@@ -958,6 +1012,16 @@ export class EventBus {
958
1012
  const value = change.value;
959
1013
  if (value.type !== "input")
960
1014
  continue;
1015
+ // Curator P5: an internal conversation's submissions are invisible
1016
+ // on every surface the bus feeds — no idle bookkeeping (the run
1017
+ // never arms the idle clock), no run.admitted/run.settled host
1018
+ // events (the SSE stream and the notifications surface see
1019
+ // nothing), and no settle-group enqueueing (no extension
1020
+ // runSettled delivery — the brief's three bus gates in one skip).
1021
+ // The flag was warmed by the pre-pass; `markInternal` runs in the
1022
+ // creating commit, which always precedes any submission.
1023
+ if (this.#isInternal(value.conversationId))
1024
+ continue;
961
1025
  // Idle bookkeeping (Task 4): a PLACED input both stamps the
962
1026
  // admission clock and marks the run live; a terminal input marks
963
1027
  // the run end. Write-type submissions never touch either — a
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The primary admin is the first stored admin in directory insertion
3
+ * order. Callers pass the live directory at each use so demotions and
4
+ * deletions take effect without a cache or a record rewrite.
5
+ */
6
+ export function primaryAdmin(records) {
7
+ return records.find((record) => record.admin === true);
8
+ }
9
+ /**
10
+ * Resolve the effective owner at use time: a live declared owner wins;
11
+ * an absent or orphaned owner falls back to the primary admin. An empty
12
+ * directory (single-operator mode) stays ownerless.
13
+ */
14
+ export function effectiveOwner(records, owner) {
15
+ if (owner !== undefined && records.some((record) => record.user === owner)) {
16
+ return owner;
17
+ }
18
+ return primaryAdmin(records)?.user;
19
+ }