@sigrun/sigrun 0.8.0 → 0.9.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 (43) hide show
  1. package/CHANGELOG.md +35 -87
  2. package/dist/config.js +47 -0
  3. package/dist/curation/host.js +125 -21
  4. package/dist/curation/runs.js +15 -4
  5. package/dist/extension.d.ts +268 -3
  6. package/dist/harness/boot.js +113 -3
  7. package/dist/harness/complete.js +127 -0
  8. package/dist/harness/decision-tool.js +93 -0
  9. package/dist/harness/decisions.js +477 -0
  10. package/dist/harness/event-bus.js +12 -0
  11. package/dist/harness/interactions.js +81 -0
  12. package/dist/harness/interactive-tools.js +97 -0
  13. package/dist/harness/loader.js +243 -1
  14. package/dist/harness/packages.js +7 -0
  15. package/dist/harness/published-hooks.js +208 -0
  16. package/dist/harness/seam-host.js +136 -1
  17. package/dist/harness/settings.js +41 -0
  18. package/dist/history/git.js +6 -2
  19. package/dist/jobs/fire.js +10 -0
  20. package/dist/jobs/observe.js +27 -2
  21. package/dist/server/conversation-ops.js +31 -0
  22. package/dist/server/core-views/skills-tree.js +3 -12
  23. package/dist/server/core-views/skills.js +11 -23
  24. package/dist/server/host-bridge.js +53 -1
  25. package/dist/server/routes/extensions.js +5 -0
  26. package/dist/server/routes/interactions.js +48 -1
  27. package/dist/server/routes/meta.js +17 -0
  28. package/dist/server/routes/providers.js +65 -8
  29. package/dist/server/routes/submissions.js +3 -0
  30. package/dist/settings/registry.js +18 -0
  31. package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
  32. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  33. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  34. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  35. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  36. package/node_modules/@sigrun/shared/dist/views.d.ts +58 -0
  37. package/node_modules/@sigrun/shared/package.json +6 -2
  38. package/package.json +6 -6
  39. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  40. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  41. package/webui/dist/index.html +2 -2
  42. package/webui/dist/assets/index-B3uMNF59.css +0 -1
  43. package/webui/dist/assets/index-BXsCyod4.js +0 -55
@@ -32,9 +32,10 @@
32
32
  * validated candidate shape and a compile-time key assertion, so the
33
33
  * implementation and this contract cannot drift silently.
34
34
  */
35
- import type { AssistantMessage, Provider } from "@earendil-works/pi-ai";
35
+ import type { AssistantMessage, Provider, TSchema } from "@earendil-works/pi-ai";
36
36
  import type { ConversationDocToken, ConversationId, Cursor, EntryId, EntryQuery, EntryRecord, Extension, InputSubmissionDraft, JsonObject, ModelRef, Page, SubmissionId } from "@earendil-works/pi-durable";
37
37
  import type { Access, Sharing } from "@sigrun/shared/access";
38
+ import type { DecisionAnswers, DecisionLimits, DecisionRequest, DecisionResult } from "@sigrun/shared/decisions";
38
39
  import type { AmbientPlace, AmbientRecord } from "@sigrun/shared/ambient";
39
40
  import type { Action, CoreTarget, DataOps, IconName, Tree } from "@sigrun/shared/blocks";
40
41
  import type { UserNotice, UserNoticeInput } from "@sigrun/shared/notices";
@@ -132,6 +133,10 @@ export type ExtensionLifecyclePayload = {
132
133
  readonly status: "answered" | "skipped" | "expired" | "cancelled";
133
134
  /** The answering user (C5 attribution), when a human answered. */
134
135
  readonly answeredByUser?: string;
136
+ /** How a DECLINE landed (ask-user Task 1/2): "button" via the decline
137
+ * route/host method, "message" via an admitted decline message. Set
138
+ * only on a declined ("skipped") record. */
139
+ readonly declinedVia?: "button" | "message";
135
140
  /** The STORED (masked-by-construction) answer values — render-safe. */
136
141
  readonly answer?: Readonly<Record<string, unknown>>;
137
142
  readonly owner?: string;
@@ -274,8 +279,28 @@ export interface CurationTarget {
274
279
  readonly name: string;
275
280
  /** Tools the review may call to write this target; registered by this extension. */
276
281
  readonly tools: readonly string[];
282
+ /**
283
+ * Read-only tools the review may call (knowledge C5): model-visible
284
+ * for serving and allowed inside a review, but NEVER wrapped into a
285
+ * review hint and never gated — the loader's wrap feed grows from
286
+ * `tools` only. Registered by this extension; disjoint from `tools`.
287
+ */
288
+ readonly readTools?: readonly string[];
277
289
  /** Routing guidance for one user; `[]` = unavailable for this user. */
278
290
  guidance(user: string): readonly string[] | Promise<readonly string[]>;
291
+ /**
292
+ * Whether this target wants the conversation reviewed (the fork path
293
+ * only — mixed-authorship conversations go to the idle digest). Core
294
+ * bounds each call at 2 s; a throw or timeout counts as `false`.
295
+ */
296
+ wantsReview?(user: string, conversationId: ConversationId): boolean | Promise<boolean>;
297
+ /**
298
+ * Called after a fork review of the conversation COMPLETED (after
299
+ * the result is recorded) — the target's signal that the review
300
+ * consumed its hints. Not called for any other status. Errors are
301
+ * caught and logged; a target must not rely on being called.
302
+ */
303
+ reviewed?(user: string, conversationId: ConversationId): void | Promise<void>;
279
304
  /** Revert one change this target reported. Required. */
280
305
  undo(user: string, changeId: string, ctx: HandlerContext): Promise<void>;
281
306
  /** Optional idle maintenance, called by the curator's idle pass. */
@@ -409,6 +434,8 @@ export type CurationEligibility = {
409
434
  export type CurationTargetInfo = {
410
435
  readonly id: string;
411
436
  readonly tools: readonly string[];
437
+ /** The target's declared read tools (C5; absent when it has none). */
438
+ readonly readTools?: readonly string[];
412
439
  readonly guidance: readonly string[];
413
440
  readonly maintain: boolean;
414
441
  };
@@ -453,8 +480,15 @@ export type SigrunCurationHost = {
453
480
  modelCallsSince(id: ConversationId, entryId?: EntryId): Promise<number>;
454
481
  /** Read and clear the conversation's pending review hints (A8's doc). */
455
482
  takeHints(id: ConversationId): Promise<readonly CurationHint[]>;
456
- /** Targets whose tools are all in the conversation's selection (no id: all). */
483
+ /** Targets whose tools (and readTools) are all in the conversation's selection (no id: all). */
457
484
  targets(user: string, id?: ConversationId): Promise<readonly CurationTargetInfo[]>;
485
+ /**
486
+ * Whether any target served for `(user, id)` requests a review (C5):
487
+ * `true` when at least one served target's `wantsReview` answers
488
+ * true. Each target's call is bounded (2 s); a throw or timeout
489
+ * counts as `false`. Fork-path only — the idle digest never asks.
490
+ */
491
+ wantsReview(user: string, id: ConversationId): Promise<boolean>;
458
492
  /** The conversation's model-visible tool names (for the allow-list). */
459
493
  tools(id: ConversationId): Promise<readonly string[]>;
460
494
  /** Fork the conversation at its newest entry and drive the review run. */
@@ -520,6 +554,40 @@ export interface ProviderReadiness {
520
554
  * three-state source `/v1/auth/check` reports verbatim.
521
555
  */
522
556
  export type ReadinessTracker = () => ProviderReadiness;
557
+ /** One decision model: a bare id plus optional limits narrower than the built-in caps. */
558
+ export interface DecisionModelInfo {
559
+ readonly id: string;
560
+ readonly limits?: DecisionLimits;
561
+ }
562
+ /** The context core stamps on every decision-provider call. */
563
+ export interface DecisionContext {
564
+ /** The bare model id (the `"<provider>/<model>"` spec's second half). */
565
+ readonly model: string;
566
+ /** auth.json[id].apiKey, else the ambient `credentialEnv`; never `"default"`. */
567
+ readonly credential: string | undefined;
568
+ readonly signal: AbortSignal;
569
+ /** The calling extension's slug, core-stamped (`"sigrun"` for the core tool). */
570
+ readonly caller: string;
571
+ }
572
+ /**
573
+ * One decision-provider contribution: typed questions (noul / choice /
574
+ * score) answered over a JSON state. A factory result carrying only
575
+ * `decisionProviders` is a valid contributor-only result.
576
+ */
577
+ export interface DecisionProviderContribution {
578
+ /** `^[a-z][a-z0-9-]*$`; the auth.json key and the model prefix. */
579
+ readonly id: string;
580
+ /** Display name. */
581
+ readonly label?: string;
582
+ /** Ambient env fallback core reads when auth.json has no key. */
583
+ readonly credentialEnv?: string;
584
+ /** The fixed model list (restart-required). */
585
+ readonly models: readonly DecisionModelInfo[];
586
+ /** The chat providers' three-state readiness tracker. */
587
+ readonly readiness?: ReadinessTracker;
588
+ /** May throw; core maps a throw to `failed`. */
589
+ evaluate(request: DecisionRequest, ctx: DecisionContext): Promise<DecisionAnswers>;
590
+ }
523
591
  /** One provider contribution: a pi-ai `createProvider()` result plus optional boot hints. */
524
592
  export interface ProviderContribution {
525
593
  readonly provider: Provider;
@@ -1017,6 +1085,12 @@ export interface SurfaceDef<P extends "view" | "panel" | "settings"> {
1017
1085
  readonly placement: P;
1018
1086
  /** Data is identical for every principal with access (§7.3). */
1019
1087
  readonly sharedData?: boolean;
1088
+ /** Panels only: the channels attach this panel with
1089
+ * `params: { conversationId }` of the OPEN conversation, so one
1090
+ * instance follows the conversation the user is in. The id is
1091
+ * client-supplied — the extension must check the caller's access to
1092
+ * that conversation before rendering or acting on it. */
1093
+ readonly perConversation?: boolean;
1020
1094
  /** Channels refetch data on this interval while visible (min 5000). */
1021
1095
  readonly refreshMs?: number;
1022
1096
  /** §7.3; default: canonical JSON of params. The principal argument
@@ -1163,6 +1237,15 @@ export interface SigrunHost {
1163
1237
  }): Promise<boolean>;
1164
1238
  /** Mark a pending interaction expired/cancelled/skipped (extension-owned semantics). */
1165
1239
  settleInteraction(conversationId: ConversationId, callId: string, status: "skipped" | "expired" | "cancelled", context: HandlerContext): Promise<boolean>;
1240
+ /**
1241
+ * Decline a pending DECLINABLE interaction as a human (ask-user Task
1242
+ * 1): status "skipped", `declinedVia: "button"`. `opts.user` names
1243
+ * the declining human (the same attribution `answerInteraction`
1244
+ * stamps); false when the record is not pending or not declinable.
1245
+ */
1246
+ declineInteraction(conversationId: ConversationId, callId: string, context: HandlerContext, opts?: {
1247
+ readonly user?: string;
1248
+ }): Promise<boolean>;
1166
1249
  /**
1167
1250
  * Inspect one interaction record WITHOUT consuming anything: returns
1168
1251
  * the same stored projection as {@link SigrunHost.snapshotInteraction}
@@ -1318,6 +1401,33 @@ export interface SigrunHost {
1318
1401
  /** Read-only job lookup; retired and unknown ids resolve to `undefined`. */
1319
1402
  readonly jobs: {
1320
1403
  get(id: string): Promise<HostJobInfo | undefined>;
1404
+ /**
1405
+ * The job whose fire is CURRENTLY running in this conversation:
1406
+ * the job with `lastRun.outcome === "running"` and
1407
+ * `lastRun.conversationId === conversationId` — `undefined` when no
1408
+ * job fire is running there. `owner` is the EFFECTIVE owner (the
1409
+ * same value `get` returns: an ownerless or orphaned job resolves
1410
+ * to the primary admin). Two matching runs resolve to the newest
1411
+ * `startedAt`.
1412
+ */
1413
+ activeRun(conversationId: ConversationId): Promise<{
1414
+ readonly jobId: string;
1415
+ readonly owner: string | undefined;
1416
+ } | undefined>;
1417
+ /**
1418
+ * Whether the conversation's CURRENT run is job-driven (ask-user
1419
+ * Task 3, spec §6): true when the session meta carries a `jobId`
1420
+ * (a `conversation: "new"` run), or any input of the conversation's
1421
+ * current run (`LiveDoc` `run.inputs`, present exactly while busy)
1422
+ * resolves to a submission whose requestId carries the job prefix
1423
+ * (`job:<fireKey>`, the prefix `jobs/fire.ts` stamps on both fire
1424
+ * shapes). Interactive tools call this in execute and refuse with an
1425
+ * error telling the model to escalate instead — the execute-time
1426
+ * guard for the conversations the creation-time filter cannot reach
1427
+ * (a job firing into an existing conversation; defence in depth
1428
+ * should the filter ever miss).
1429
+ */
1430
+ isJobRun(conversationId: ConversationId, context: HandlerContext): Promise<boolean>;
1321
1431
  };
1322
1432
  /**
1323
1433
  * Send one explicit notice through every registered notifier. Validation
@@ -1391,10 +1501,54 @@ export interface SigrunHost {
1391
1501
  };
1392
1502
  /**
1393
1503
  * The served model catalog (gateway C4b): the SAME list `GET
1394
- * /v1/models` serves (picker entries — no provider internals).
1504
+ * /v1/models` serves (picker entries — no provider internals), plus
1505
+ * one-shot completions with NO conversation (warden Task 4 —
1506
+ * skill-card generation is the first consumer).
1395
1507
  */
1396
1508
  readonly models: {
1397
1509
  list(): Promise<readonly ModelEntry[]>;
1510
+ /**
1511
+ * Run ONE one-shot completion and return its concatenated text
1512
+ * parts (non-text parts skipped) plus the `provider/id` of the
1513
+ * model actually used. Model resolution: `input.model` wins;
1514
+ * else the live settings' `namingModel` when it is a string
1515
+ * (`false` counts as unset — the utility role's binding); else
1516
+ * the boot default model. THROWS (never resolves silently) on an
1517
+ * unserved model (naming it), a timeout (default 60 000 ms), or
1518
+ * a `stopReason` of `error`/`aborted`.
1519
+ */
1520
+ complete(input: {
1521
+ readonly model?: string;
1522
+ readonly role?: "utility";
1523
+ readonly systemPrompt?: string;
1524
+ readonly prompt: string;
1525
+ readonly timeoutMs?: number;
1526
+ readonly signal?: AbortSignal;
1527
+ }): Promise<{
1528
+ readonly text: string;
1529
+ readonly model: string;
1530
+ }>;
1531
+ };
1532
+ /**
1533
+ * Decision providers (warden Task 5 — the decision-providers spec):
1534
+ * typed questions (noul / choice / score) answered over a JSON state.
1535
+ * `evaluate` NEVER throws; failures arrive as the `ok: false` half of
1536
+ * {@link DecisionResult}. Model resolution: `opts.model` wins; else
1537
+ * the live `decisionModel` setting; else the first model of the
1538
+ * first-registered decision provider.
1539
+ */
1540
+ readonly decisions: {
1541
+ evaluate(request: DecisionRequest, opts?: {
1542
+ model?: string;
1543
+ signal?: AbortSignal;
1544
+ timeoutMs?: number;
1545
+ }): Promise<DecisionResult>;
1546
+ /** Every decision model, with its provider's readiness status. */
1547
+ list(): Promise<readonly {
1548
+ model: string;
1549
+ provider: string;
1550
+ status: ProviderReadiness["status"];
1551
+ }[]>;
1398
1552
  };
1399
1553
  /**
1400
1554
  * The slash-command surface (gateway C4b): the merged catalog for
@@ -1426,6 +1580,28 @@ export interface SigrunHost {
1426
1580
  * for the bridge, never a silent drop.
1427
1581
  */
1428
1582
  readonly curation: SigrunCurationHost;
1583
+ /**
1584
+ * The published-hook surface (knowledge C7): look up a hook another
1585
+ * extension published (`get` returns `undefined` while loading is
1586
+ * still running and for unowned names) or call it through the core
1587
+ * registry. `call` NEVER throws: an unowned name is `absent`, a
1588
+ * payload the owner's schema rejects is `rejected`, and a provider
1589
+ * failure is `failed` with a reason. A call issued before loading
1590
+ * finishes waits for it — never `await` one inside a factory body
1591
+ * (stash the host; call from a tool, handler, worker, or job).
1592
+ */
1593
+ readonly publishedHooks: {
1594
+ get(name: string): {
1595
+ provider: string;
1596
+ input: TSchema;
1597
+ output?: TSchema;
1598
+ } | undefined;
1599
+ call(name: string, payload: unknown, opts?: {
1600
+ user?: string;
1601
+ conversationId?: ConversationId;
1602
+ abortSignal?: AbortSignal;
1603
+ }): Promise<PublishedHookResult>;
1604
+ };
1429
1605
  }
1430
1606
  /**
1431
1607
  * The documented factory-result contract: EVERY field an extension may
@@ -1449,6 +1625,8 @@ export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
1449
1625
  readonly name?: string;
1450
1626
  /** Provider contributions, merged into the boot model catalog (last-registered-wins, collisions recorded). */
1451
1627
  readonly providers?: readonly ProviderContribution[];
1628
+ /** Decision-provider contributions (warden Task 5); last-registered wins per id. */
1629
+ readonly decisionProviders?: readonly DecisionProviderContribution[];
1452
1630
  /** External identity providers; core qualifies ids as `login:<slug>:<id>`. */
1453
1631
  readonly loginProviders?: readonly LoginProviderContribution[];
1454
1632
  /** Webui-bundle contributions; each `dir` anchors at the extension ENTRY's directory. */
@@ -1459,6 +1637,17 @@ export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
1459
1637
  readonly runSettled?: ExtensionRunSettledHandler;
1460
1638
  /** Declared interaction kinds (the seam's write gate + capability inventory). */
1461
1639
  readonly interactionKinds?: readonly string[];
1640
+ /**
1641
+ * Tool names this extension registers that need a human present
1642
+ * (ask-user Task 3, spec §6). Validated at load like
1643
+ * `interactionKinds` (each name must name a tool this extension's
1644
+ * `extension.tools` registers; a bad declaration quarantines). Core
1645
+ * hides the named tools in every non-interactive run (job run
1646
+ * conversations, curator runs); in a job-fired EXISTING conversation
1647
+ * the tool stays listed and must refuse at execute time — guard with
1648
+ * `host.jobs.isJobRun`.
1649
+ */
1650
+ readonly interactiveTools?: readonly string[];
1462
1651
  /** Declared ambient surface keys (the seam's write gate + capability inventory). */
1463
1652
  readonly ambientKinds?: readonly string[];
1464
1653
  /** Idle workers (the curator contract — `wakeOn` only, `every` retired). */
@@ -1506,6 +1695,82 @@ export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
1506
1695
  readonly gateway?: GatewayEngine;
1507
1696
  /** Declared surfaces — views and panels (UI-blocks U3, spec §7.1; the service lands in Task 2). */
1508
1697
  readonly surfaces?: SurfacesContribution;
1698
+ /**
1699
+ * Published hooks (knowledge C7): named, schema-validated calls this
1700
+ * extension offers OTHER extensions through `host.publishedHooks`.
1701
+ * Core compiles each schema once at load; a malformed declaration
1702
+ * (bad name, duplicate within the extension, non-object input,
1703
+ * non-function `run`, a schema that fails to compile) quarantines
1704
+ * the extension. The first configured provider of a name owns it;
1705
+ * configured providers beat built-ins.
1706
+ */
1707
+ readonly publishedHooks?: readonly PublishedHook[];
1708
+ }
1709
+ /**
1710
+ * A published hook (knowledge plan, Task 1): a named, schema-validated
1711
+ * call one extension publishes for OTHER extensions to invoke through
1712
+ * the core registry (the extension-to-extension seam). `name` must
1713
+ * match `/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/` (at least one dot,
1714
+ * at most 128 chars). `run` receives the validated payload plus the
1715
+ * {@link PublishedHookCall} context and resolves with the result.
1716
+ */
1717
+ export interface PublishedHook {
1718
+ readonly name: string;
1719
+ readonly description?: string;
1720
+ readonly input: TSchema;
1721
+ readonly output?: TSchema;
1722
+ run(payload: unknown, call: PublishedHookCall): Promise<unknown>;
1723
+ }
1724
+ /**
1725
+ * The call context a published hook's `run` sees: the calling extension's
1726
+ * name plus the resolved identity. `user` comes from the conversation's
1727
+ * acting user when a `conversationId` is given, else the caller's
1728
+ * declared user; a caller-declared user that disagrees with the
1729
+ * conversation owner is rejected before the provider ever runs.
1730
+ */
1731
+ export interface PublishedHookCall {
1732
+ readonly caller: string;
1733
+ readonly user: string | undefined;
1734
+ readonly conversationId: ConversationId | undefined;
1735
+ readonly abortSignal: AbortSignal | undefined;
1736
+ }
1737
+ /**
1738
+ * The outcome of one registry call. `absent` — no provider owns the
1739
+ * name. `ok` — the provider ran and produced a valid result. `rejected`
1740
+ * — the call never ran (oversize/unstringifiable payload, schema
1741
+ * mismatch, or identity disagreement). `failed` — the provider ran (or
1742
+ * was interrupted) and could not deliver: `threw`, `timeout`, `aborted`,
1743
+ * `bad-output`, `too-large`, or `too-deep` (depth bound).
1744
+ */
1745
+ export type PublishedHookResult = {
1746
+ status: "absent";
1747
+ } | {
1748
+ status: "ok";
1749
+ provider: string;
1750
+ value: unknown;
1751
+ } | {
1752
+ status: "rejected";
1753
+ provider: string;
1754
+ errors: {
1755
+ path: string;
1756
+ message: string;
1757
+ }[];
1758
+ } | {
1759
+ status: "failed";
1760
+ provider: string;
1761
+ reason: "threw" | "timeout" | "aborted" | "bad-output" | "too-large" | "too-deep";
1762
+ error: string;
1763
+ };
1764
+ /**
1765
+ * One extension's declaration of a published hook, in loader order.
1766
+ * `builtin` marks core-bundled extensions: when a configured extension
1767
+ * shadows a built-in the warning names replacing the built-in instead
1768
+ * of reordering the extension list.
1769
+ */
1770
+ export interface PublishedHookDeclaration {
1771
+ readonly extension: string;
1772
+ readonly builtin: boolean;
1773
+ readonly hook: PublishedHook;
1509
1774
  }
1510
1775
  /**
1511
1776
  * The extension factory: sigrun calls it at load, passing the host
@@ -11,7 +11,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
11
  import { join } from "node:path";
12
12
  import { Harness, } from "@earendil-works/pi-durable";
13
13
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
14
- import { effectiveMethods } from "../config.js";
14
+ import { DECISION_TIMEOUT_ENV, effectiveMethods } from "../config.js";
15
15
  import { requiresProxyVouch } from "../server/auth/proxy-only.js";
16
16
  import { wireActivity } from "./activity.js";
17
17
  import { snapshotBeforeMigration } from "./migration-snapshot.js";
@@ -29,6 +29,9 @@ import { AuthStore } from "./auth.js";
29
29
  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
+ import { buildPublishedHookRegistry, } from "./published-hooks.js";
33
+ import { buildDecisionRegistry, } from "./decisions.js";
34
+ import { buildDecisionEvaluateTool, createDecisionsExtension, } from "./decision-tool.js";
32
35
  import { contextFilesExtension } from "./sections/context-files.js";
33
36
  import { createJobStateExtension } from "./sections/job-state.js";
34
37
  import { modelGuidanceExtension } from "./sections/model-guidance.js";
@@ -653,6 +656,15 @@ export async function openHarness(config, options = {}) {
653
656
  // can show and update interactive block surfaces in chat — the same
654
657
  // core-owned slot as the coding/search tools, never quarantined.
655
658
  registry.install(blocksToolsExtension);
659
+ // The decision tool (warden Task 7): installed HERE with an empty
660
+ // tool list — the placeholder holding the core `sigrun.decisions`
661
+ // name — because the decision declarations are only known AFTER the
662
+ // loader runs. Once the registry is built (below), boot RE-INSTALLS
663
+ // the extension with `decision_evaluate` when the registry lists ≥ 1
664
+ // decision model (`registry.install` replaces a same-name extension
665
+ // in place); a no-provider boot keeps the placeholder and the tool
666
+ // is never offered.
667
+ registry.install(createDecisionsExtension());
656
668
  // Extension packages load before `Harness.open` reads the registry
657
669
  // (spec §3 boot order). Failures quarantine and boot continues. §3
658
670
  // factory-result producers are retained alongside the outcomes (Task 9e).
@@ -732,6 +744,31 @@ export async function openHarness(config, options = {}) {
732
744
  });
733
745
  const bindHarness = (harness) => release(harness);
734
746
  const harnessOf = () => harnessPromise;
747
+ // The published-hook registry slot (knowledge C7): the deferred MUST
748
+ // exist before `loadExtensions` runs — seam hosts are built inside the
749
+ // loader's hostContextFactory and close over this slot. Boot builds
750
+ // the registry from the loader's declarations right after
751
+ // `loadExtensions` returns and resolves the deferred; a
752
+ // `host.publishedHooks.call` issued during loading waits on it
753
+ // (the harnessPromise pattern), a `get` before then reads
754
+ // `undefined`.
755
+ let publishRegistryReady;
756
+ const publishedHooksReady = new Promise((resolve) => {
757
+ publishRegistryReady = resolve;
758
+ });
759
+ const publishedHooksSlot = { registry: undefined };
760
+ // The decision-registry slot (warden Task 6): the SAME deferred
761
+ // discipline as the published-hook slot above — seam hosts are built
762
+ // inside the loader's hostContextFactory (before the registry can
763
+ // exist) and close over this slot. Boot builds the registry from the
764
+ // loader's declarations right after `loadExtensions` returns and
765
+ // resolves the deferred; a `host.decisions.evaluate` issued from a
766
+ // factory body waits on it.
767
+ let decisionRegistryReady;
768
+ const decisionsReady = new Promise((resolve) => {
769
+ decisionRegistryReady = resolve;
770
+ });
771
+ const decisionsSlot = { registry: undefined };
735
772
  // The boot-shared store trio (Task 5): one SecretsStore + one
736
773
  // ExtensionSettingsStore over the roots resolved at the top of boot;
737
774
  // each seam host scopes it to its extension's slug (cheap, no I/O —
@@ -741,7 +778,7 @@ export async function openHarness(config, options = {}) {
741
778
  const sharedStores = sharedExtensionStores(roots, env, identity.users);
742
779
  const seamHosts = new Map();
743
780
  const seamKinds = new Map();
744
- const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, curationBindings, noticeActionBindings, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
781
+ const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, curationBindings, noticeActionBindings, publishedHookDeclarations, decisionProviderDeclarations, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, interactiveTools: loadedInteractiveTools, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
745
782
  packagesDir,
746
783
  // Curator A8 (P9): the wrap feed — the loader grows it with each
747
784
  // validated target tool and wraps those tools at their install.
@@ -764,7 +801,10 @@ export async function openHarness(config, options = {}) {
764
801
  }, sharedStores, {
765
802
  declared: (slug) => perUserSlugs.includes(slug),
766
803
  set: (slug, user, tools) => userTools.set(slug, user, tools),
767
- }, userServices);
804
+ }, userServices, {
805
+ publishedHooks: { current: publishedHooksSlot, ready: publishedHooksReady },
806
+ decisions: { current: decisionsSlot, ready: decisionsReady },
807
+ });
768
808
  host.rekey({ name: extensionName }, kinds);
769
809
  // File under the final name only, and only once the load
770
810
  // resolved: the PRE-import call must NOT file (its identity is
@@ -797,6 +837,65 @@ export async function openHarness(config, options = {}) {
797
837
  // wrapping feed (isCurationTool) and the A9 host.curation surface
798
838
  // read it. An empty fold is the no-declarer deploy.
799
839
  const curation = buildCurationRegistry(curationBindings);
840
+ // The published-hook registry (knowledge C7): built from the loader's
841
+ // collected declarations the moment `loadExtensions` returns, then the
842
+ // deferred resolves — a `host.publishedHooks.call` issued from a
843
+ // factory body (still in flight) proceeds from here. `actingUser`
844
+ // reads through the harness deferred: a call whose conversation
845
+ // identity resolves during the load window waits for `Harness.open`
846
+ // exactly like the other bridged members. Load order between caller
847
+ // and provider never matters — lookup is at call time.
848
+ publishedHooksSlot.registry = buildPublishedHookRegistry(publishedHookDeclarations, {
849
+ actingUser: async (id) => {
850
+ const h = await harnessPromise;
851
+ return (await h.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT))
852
+ ?.owner;
853
+ },
854
+ deadlineMs: config.publishedHookDeadlineMs,
855
+ warn: (message) => console.warn(message),
856
+ });
857
+ // The decision registry (warden Task 6): built from the loader's
858
+ // collected declarations the moment `loadExtensions` returns (the
859
+ // published-hook pattern above), then the deferred resolves — a
860
+ // `host.decisions.evaluate` issued from a factory body (still in
861
+ // flight) proceeds from here. The credential resolves PER CALL:
862
+ // `auth.json[id].apiKey` (a live read, so a key written through the
863
+ // providers route applies without a restart) wins over the ambient
864
+ // `credentialEnv` with the auth bridge's `""`-means-unset rule; the
865
+ // magic `"default"` key never applies (it names the default CHAT
866
+ // provider). The settings read is live too: `decisionModel` and
867
+ // `server.decisions.timeoutMs` apply without a restart.
868
+ decisionsSlot.registry = buildDecisionRegistry(decisionProviderDeclarations, {
869
+ credential: (id, credentialEnv) => {
870
+ const stored = authStore.read()[id]?.apiKey;
871
+ if (stored !== undefined)
872
+ return stored;
873
+ return credentialEnv === undefined
874
+ ? undefined
875
+ : env[credentialEnv]?.trim() || undefined;
876
+ },
877
+ readSettings: () => settingsStore.read(),
878
+ timeoutCapMs: config.decisions.timeoutMs,
879
+ // The env tier of the timeout knob: env always wins over a live
880
+ // file edit (the config's env-over-file precedence, per call).
881
+ ...(env[DECISION_TIMEOUT_ENV] === undefined || env[DECISION_TIMEOUT_ENV] === ""
882
+ ? {}
883
+ : { timeoutMs: config.decisions.timeoutMs }),
884
+ now: () => Date.now(),
885
+ warn: (message) => console.warn(message),
886
+ });
887
+ decisionRegistryReady();
888
+ // Warden Task 7: the late fill — swap the placeholder extension for
889
+ // the tool-bearing one when any decision model exists at boot.
890
+ if (decisionsSlot.registry !== undefined && decisionsSlot.registry.list().length > 0) {
891
+ registry.install(createDecisionsExtension([
892
+ buildDecisionEvaluateTool({
893
+ decisions: decisionsSlot.registry,
894
+ settings: settingsStore,
895
+ }),
896
+ ]));
897
+ }
898
+ publishRegistryReady();
800
899
  for (const binding of userServiceBindings) {
801
900
  userServices.register(binding.extension, binding.services, binding.packageDir);
802
901
  }
@@ -925,6 +1024,12 @@ export async function openHarness(config, options = {}) {
925
1024
  // `typed` entry — there is no user turn to attribute).
926
1025
  commandExpansion,
927
1026
  envBuilder: undefined,
1027
+ // Ask-user Task 3: job fires hide interactive tools (assigned with
1028
+ // the rest of the late-bound deps after the load completes).
1029
+ // SAFETY: the placeholder is assigned below, before any fireJob
1030
+ // caller can run (the same late-binding pattern as `harness`/
1031
+ // `storage`/`envBuilder` on this deps object).
1032
+ interactiveTools: undefined,
928
1033
  // Gateway C7: a deliver-carrying fire warns once per run when no
929
1034
  // engine loaded (core ships none — `gatewayEngine` is undefined
930
1035
  // unless an extension declared one).
@@ -1176,6 +1281,7 @@ export async function openHarness(config, options = {}) {
1176
1281
  // JobsService — happens after this point.
1177
1282
  jobRuntimeDeps.harness = harness;
1178
1283
  jobRuntimeDeps.storage = storage;
1284
+ jobRuntimeDeps.interactiveTools = new Set(loadedInteractiveTools);
1179
1285
  // Drain the restart-limit parks reported during open before scheduling
1180
1286
  // resumes. A failed status patch is logged and dropped by applyParkedJobs.
1181
1287
  await applyParkedJobs(harness, reports.takeParked(), BACKGROUND_CONTEXT);
@@ -1252,6 +1358,7 @@ export async function openHarness(config, options = {}) {
1252
1358
  storage,
1253
1359
  curation,
1254
1360
  defaultModel,
1361
+ interactiveTools: new Set(loadedInteractiveTools),
1255
1362
  stores: curationOf,
1256
1363
  skillsOptions: {
1257
1364
  userDir: join(roots.configDir, "skills"),
@@ -1496,6 +1603,7 @@ export async function openHarness(config, options = {}) {
1496
1603
  reports,
1497
1604
  jobRunTotals: () => jobRunTotals,
1498
1605
  defaultModel,
1606
+ interactiveTools: new Set(loadedInteractiveTools),
1499
1607
  providerCollisions: sink.collisions,
1500
1608
  webuiBundles,
1501
1609
  webuiBundleCollisions,
@@ -1511,6 +1619,7 @@ export async function openHarness(config, options = {}) {
1511
1619
  identity,
1512
1620
  memoryHealth,
1513
1621
  settings: settingsStore,
1622
+ decisions: decisionsSlot.registry,
1514
1623
  extensionStores: sharedStores,
1515
1624
  packagesDir,
1516
1625
  seamHosts,
@@ -1535,6 +1644,7 @@ export async function openHarness(config, options = {}) {
1535
1644
  curation,
1536
1645
  curationHost,
1537
1646
  curationOf,
1647
+ publishedHooks: () => publishedHooksSlot.registry?.listing() ?? [],
1538
1648
  workspaceRootOf: (owner) => workspaceService?.workspaceRoot(owner),
1539
1649
  history: { workspace: workspaceHistoryOf },
1540
1650
  commandExpansion,