@sigrun/sigrun 0.7.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 (56) hide show
  1. package/CHANGELOG.md +78 -59
  2. package/README.md +32 -0
  3. package/dist/config.js +47 -0
  4. package/dist/curation/host.js +125 -21
  5. package/dist/curation/runs.js +15 -4
  6. package/dist/extension.d.ts +331 -4
  7. package/dist/harness/boot.js +114 -3
  8. package/dist/harness/complete.js +127 -0
  9. package/dist/harness/decision-tool.js +93 -0
  10. package/dist/harness/decisions.js +477 -0
  11. package/dist/harness/event-bus.js +12 -0
  12. package/dist/harness/interactions.js +81 -0
  13. package/dist/harness/interactive-tools.js +97 -0
  14. package/dist/harness/loader.js +285 -1
  15. package/dist/harness/packages.js +7 -0
  16. package/dist/harness/published-hooks.js +208 -0
  17. package/dist/harness/seam-host.js +149 -1
  18. package/dist/harness/settings.js +41 -0
  19. package/dist/history/git.js +6 -2
  20. package/dist/jobs/fire.js +10 -0
  21. package/dist/jobs/observe.js +27 -2
  22. package/dist/notices/events.js +8 -0
  23. package/dist/notices/service.js +297 -0
  24. package/dist/notices/store.js +331 -0
  25. package/dist/notices/validate.js +311 -0
  26. package/dist/server/conversation-ops.js +50 -0
  27. package/dist/server/core-views/skills-tree.js +3 -12
  28. package/dist/server/core-views/skills.js +11 -23
  29. package/dist/server/host-bridge.js +62 -1
  30. package/dist/server/index.js +26 -0
  31. package/dist/server/routes/events.js +20 -0
  32. package/dist/server/routes/extensions.js +5 -0
  33. package/dist/server/routes/interactions.js +48 -1
  34. package/dist/server/routes/meta.js +17 -0
  35. package/dist/server/routes/notices.js +94 -0
  36. package/dist/server/routes/providers.js +65 -8
  37. package/dist/server/routes/submissions.js +3 -0
  38. package/dist/server/routes/users.js +4 -0
  39. package/dist/server/surfaces/dedup.js +6 -0
  40. package/dist/server/visibility.js +30 -0
  41. package/dist/settings/registry.js +18 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  43. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  44. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  45. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  46. package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
  47. package/node_modules/@sigrun/shared/dist/notices.js +49 -0
  48. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  49. package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
  50. package/node_modules/@sigrun/shared/package.json +9 -1
  51. package/package.json +7 -7
  52. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  53. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  54. package/webui/dist/index.html +2 -2
  55. package/webui/dist/assets/index-BI5k-Oci.js +0 -55
  56. package/webui/dist/assets/index-EGluyRXk.css +0 -1
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Interactive tools (ask-user Task 3, spec §6): the helpers that hide an
3
+ * extension's `interactiveTools` — tool names that need a human present —
4
+ * from every NON-INTERACTIVE run core creates.
5
+ *
6
+ * Core owns the definition of a non-interactive conversation (spec §6):
7
+ * scheduled job run conversations and the curator's internal runs. In
8
+ * those conversations core leaves interactive tools out of the offered
9
+ * tools at creation time by removing them from the ENABLED-tools filter
10
+ * (pi-durable `AgentDoc.tools`): a removal there makes the tool neither
11
+ * offered to the model NOR reachable from nested `executeTool()` calls
12
+ * (the nested resolution reads the same enabled set), which no
13
+ * `modelTools` narrowing could give.
14
+ *
15
+ * Fork reviews deliberately do NOT pass through here — they copy the
16
+ * parent's stored selection for byte-identical prompt prefixes (cache
17
+ * parity), and the curation gate blocks an interactive call instead.
18
+ *
19
+ * Jobs that fire into an EXISTING conversation are not covered by this
20
+ * creation-time filter either (the conversation predates the fire);
21
+ * the tool stays listed and must guard its own execute with
22
+ * `host.jobs.isJobRun` (spec §6's option A).
23
+ */
24
+ import { AgentDoc, LiveDoc } from "@earendil-works/pi-durable";
25
+ import { SessionMetaDoc } from "../server/session-meta.js";
26
+ /**
27
+ * Remove every name in `names` from the conversation's stored
28
+ * `AgentDoc.tools` enabled filter, preserving the stored shape:
29
+ * - unset → `{remove: [...names].sort()}`;
30
+ * - an object → `remove ∪ names` (sorted);
31
+ * - an array → the array without the names.
32
+ *
33
+ * A no-op when `names` is empty (the no-interactive-extension deploy
34
+ * writes nothing). Runs inside an existing commit (the callers are the
35
+ * creating commits and the server create op's post-create stamp commit);
36
+ * document access only — no table reads.
37
+ */
38
+ export async function excludeInteractiveTools(tx, conversationId, names) {
39
+ if (names.size === 0)
40
+ return;
41
+ const agent = await tx.doc(AgentDoc, conversationId);
42
+ const stored = agent.tools;
43
+ if (stored === undefined) {
44
+ agent.tools = { remove: [...names].sort() };
45
+ return;
46
+ }
47
+ if (Array.isArray(stored)) {
48
+ agent.tools = stored.filter((name) => !names.has(name));
49
+ return;
50
+ }
51
+ agent.tools = {
52
+ remove: [...new Set([...stored.remove, ...names])].sort(),
53
+ };
54
+ }
55
+ /**
56
+ * Whether the conversation's CURRENT run is job-driven (ask-user Task 3,
57
+ * spec §6): true when the session meta carries a `jobId` (a
58
+ * `conversation: "new"` run — the stamp persists past the run's settle,
59
+ * which is fine: every run of that conversation is a job run), or any
60
+ * input of the conversation's current run (`LiveDoc` `run.inputs`,
61
+ * present exactly while busy) resolves to a submission whose requestId
62
+ * carries the job prefix (`job:<fireKey>`, stamped by `jobs/fire.ts` on
63
+ * BOTH fire shapes — that branch covers a job firing into an EXISTING
64
+ * conversation, which carries no `jobId`). Interactive tools call this in
65
+ * execute and refuse instead of waiting on a human.
66
+ */
67
+ export async function isJobRunConversation(harness, conversationId, context) {
68
+ const meta = await harness.snapshot(SessionMetaDoc, conversationId, context);
69
+ if (meta?.jobId !== undefined)
70
+ return true;
71
+ const live = await harness.snapshot(LiveDoc, conversationId, context);
72
+ for (const id of live?.run?.inputs ?? []) {
73
+ const submission = await harness.submission(id, context);
74
+ if (submission === undefined)
75
+ continue;
76
+ const record = await submission.status(context);
77
+ if (record.requestId?.startsWith("job:"))
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * Filter `list` to the names outside `names` (a curator run's `allow`
84
+ * list losing its interactive members). Never fails — stripping must not
85
+ * let installing an interactive-tool extension break unrelated curation
86
+ * targets — but warns ONCE per call naming the removed tools and `where`
87
+ * (an all-interactive list then still hits the existing
88
+ * non-empty-`allow` check, loudly). Returns a fresh array in every case
89
+ * (the tracker requires alias-free roots).
90
+ */
91
+ export function withoutInteractive(list, names, warn, where) {
92
+ const removed = list.filter((name) => names.has(name));
93
+ if (removed.length === 0)
94
+ return [...list];
95
+ warn(`interactive tools [${removed.join(", ")}] removed from the ${where} allow list — they need a human present`);
96
+ return list.filter((name) => !names.has(name));
97
+ }
@@ -92,7 +92,9 @@ import { fileURLToPath } from "node:url";
92
92
  import { satisfies, validRange } from "semver";
93
93
  import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
94
94
  import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
95
+ import { Compile } from "typebox/compile";
95
96
  import { slugifyName } from "./stores.js";
97
+ import { PUBLISHED_HOOK_NAME, PUBLISHED_HOOK_NAME_MAX_CHARS, } from "./published-hooks.js";
96
98
  import { wrapExtensionCurationTools } from "../curation/wrap.js";
97
99
  /** Every lifecycle field name, in validation order (mirrors the contract doc). */
98
100
  const LIFECYCLE_FIELDS = [
@@ -142,6 +144,29 @@ function validateKinds(value) {
142
144
  }
143
145
  return null;
144
146
  }
147
+ /**
148
+ * Validate the `interactiveTools` declaration (ask-user Task 3, spec §6):
149
+ * an array of non-empty strings, every one naming a tool the extension's
150
+ * OWN `extension.tools` registers (hiding a tool the extension never
151
+ * registered is a declaration bug — it quarantines, loudly).
152
+ */
153
+ function validateInteractiveTools(value, extension) {
154
+ if (value === undefined)
155
+ return undefined;
156
+ if (!Array.isArray(value)) {
157
+ throw new Error("interactiveTools must be an array of strings");
158
+ }
159
+ if (value.some((name) => typeof name !== "string" || name.length === 0)) {
160
+ throw new Error("interactiveTools must be an array of non-empty strings");
161
+ }
162
+ const registered = new Set(extension?.tools?.map((tool) => tool.name) ?? []);
163
+ for (const name of value) {
164
+ if (!registered.has(name)) {
165
+ throw new Error(`interactiveTools names "${name}", which this extension does not register`);
166
+ }
167
+ }
168
+ return value;
169
+ }
145
170
  /**
146
171
  * The lifecycle event names a worker's `wakeOn` may declare (memory-seam
147
172
  * Task 5): `harnessShutdown` is excluded — workers stop BEFORE the shutdown
@@ -232,6 +257,152 @@ function validateWorkers(value) {
232
257
  * does not register) and the cross-extension duplicate qualified id
233
258
  * live in the load loop, where the registry and the slug are known.
234
259
  */
260
+ /**
261
+ * The notice-action handler-name grammar (notices Task 3): the key a
262
+ * posted notice's `extension` action names, and the loader's per-key
263
+ * check on the factory field.
264
+ */
265
+ const NOTICE_ACTION_NAME_RE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/;
266
+ /**
267
+ * `noticeActions` must be a plain object whose keys satisfy the
268
+ * handler-name grammar and whose values are functions (notices Task 3);
269
+ * anything else quarantines the extension — an extension that THINKS it
270
+ * serves notice buttons must not silently lose them.
271
+ */
272
+ function validateNoticeActions(value) {
273
+ if (value === undefined)
274
+ return undefined;
275
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
276
+ throw new Error("extension factory returned a noticeActions field that is not a plain object");
277
+ }
278
+ const actions = {};
279
+ for (const [name, handler] of Object.entries(value)) {
280
+ if (!NOTICE_ACTION_NAME_RE.test(name)) {
281
+ throw new Error(`extension factory returned a noticeActions entry with an invalid name "${name}"`);
282
+ }
283
+ if (typeof handler !== "function") {
284
+ throw new Error(`extension factory returned a noticeActions entry "${name}" that is not a function`);
285
+ }
286
+ actions[name] = handler;
287
+ }
288
+ return actions;
289
+ }
290
+ /**
291
+ * Validate a factory result's `publishedHooks` declaration (knowledge C7).
292
+ * Absent → undefined (no hooks). Present but malformed → throw; the loader
293
+ * quarantines the extension with the thrown message (fail-loud: an
294
+ * extension that THINKS it published a hook must not silently lose it).
295
+ * Per entry: a string `name` matching {@link PUBLISHED_HOOK_NAME} (and the
296
+ * length bound), no intra-extension duplicates, `input` an object, `run` a
297
+ * function — plus a `Compile` of both schemas, which is what makes the
298
+ * registry build's compile-throw unreachable in production.
299
+ */
300
+ function validatePublishedHooks(value) {
301
+ if (value === undefined)
302
+ return undefined;
303
+ if (!Array.isArray(value)) {
304
+ throw new Error("extension factory returned a publishedHooks field that is not an array");
305
+ }
306
+ const seen = new Set();
307
+ for (const entry of value) {
308
+ const hook = entry;
309
+ const name = hook?.name;
310
+ if (typeof name !== "string" ||
311
+ name.length > PUBLISHED_HOOK_NAME_MAX_CHARS ||
312
+ !PUBLISHED_HOOK_NAME.test(name)) {
313
+ throw new Error(`extension factory returned a published hook with an invalid name ${JSON.stringify(name)} (must match ${PUBLISHED_HOOK_NAME.source}, ≤${PUBLISHED_HOOK_NAME_MAX_CHARS} chars)`);
314
+ }
315
+ if (seen.has(name)) {
316
+ throw new Error(`extension factory returned a duplicate published hook name "${name}"`);
317
+ }
318
+ seen.add(name);
319
+ if (typeof hook?.input !== "object" ||
320
+ hook.input === null ||
321
+ Array.isArray(hook.input)) {
322
+ throw new Error(`extension factory returned a published hook "${name}" whose input is not a schema object`);
323
+ }
324
+ if (hook.output !== undefined && (typeof hook.output !== "object" || hook.output === null)) {
325
+ throw new Error(`extension factory returned a published hook "${name}" whose output is not a schema object`);
326
+ }
327
+ if (typeof hook?.run !== "function") {
328
+ throw new Error(`extension factory returned a published hook "${name}" whose run is not a function`);
329
+ }
330
+ // Compile ONCE here: a schema that fails to compile quarantines the
331
+ // extension at load, so the registry build never sees one.
332
+ try {
333
+ Compile(hook.input);
334
+ if (hook.output !== undefined)
335
+ Compile(hook.output);
336
+ }
337
+ catch (err) {
338
+ throw new Error(`extension factory returned a published hook "${name}" whose schema fails to compile: ${err instanceof Error ? err.message : String(err)}`);
339
+ }
340
+ }
341
+ return value;
342
+ }
343
+ /** The decision-provider id grammar (warden Task 6): also the auth.json key. */
344
+ const DECISION_PROVIDER_ID = /^[a-z][a-z0-9-]*$/;
345
+ /** The decision-provider id length bound. */
346
+ const DECISION_PROVIDER_ID_MAX_CHARS = 64;
347
+ /** The decision-model id grammar (the `"<provider>/<model>"` spec's second half). */
348
+ const DECISION_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
349
+ /** The `credentialEnv` grammar: a process-env NAME. */
350
+ const DECISION_CREDENTIAL_ENV = /^[A-Z][A-Z0-9_]*$/;
351
+ /**
352
+ * Validate a factory result's `decisionProviders` declaration (warden
353
+ * Task 6). Absent → undefined (no contributions). Present but malformed
354
+ * → throw; the loader quarantines the extension with the thrown message
355
+ * (fail-loud: an extension that THINKS it contributed a decision
356
+ * provider must not silently lose it — the providers/health class).
357
+ * Per entry: a string `id` matching {@link DECISION_PROVIDER_ID} (≤64
358
+ * chars), at least one model whose ids match {@link DECISION_MODEL_ID}
359
+ * with no intra-provider duplicates, `evaluate` a function, `readiness`
360
+ * absent or a function, and `credentialEnv` absent or a NAME.
361
+ */
362
+ function validateDecisionProviders(value) {
363
+ if (value === undefined)
364
+ return undefined;
365
+ if (!Array.isArray(value)) {
366
+ throw new Error("extension factory returned a decisionProviders field that is not an array");
367
+ }
368
+ for (const entry of value) {
369
+ const provider = entry;
370
+ if (typeof provider?.id !== "string" ||
371
+ provider.id.length > DECISION_PROVIDER_ID_MAX_CHARS ||
372
+ !DECISION_PROVIDER_ID.test(provider.id)) {
373
+ throw new Error(`extension factory returned a decision provider with an invalid id ${JSON.stringify(provider?.id)} (must match ${DECISION_PROVIDER_ID.source}, ≤${DECISION_PROVIDER_ID_MAX_CHARS} chars)`);
374
+ }
375
+ if (!Array.isArray(provider.models) ||
376
+ provider.models.length === 0) {
377
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with no models`);
378
+ }
379
+ const seen = new Set();
380
+ for (const model of provider.models) {
381
+ const modelId = model?.id;
382
+ if (typeof modelId !== "string" ||
383
+ !DECISION_MODEL_ID.test(modelId)) {
384
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with an invalid model id ${JSON.stringify(modelId)} (must match ${DECISION_MODEL_ID.source})`);
385
+ }
386
+ if (seen.has(modelId)) {
387
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with a duplicate model id "${modelId}"`);
388
+ }
389
+ seen.add(modelId);
390
+ }
391
+ if (typeof provider.evaluate !== "function") {
392
+ throw new Error(`extension factory returned a decision provider "${provider.id}" whose evaluate is not a function`);
393
+ }
394
+ if (provider.readiness !== undefined &&
395
+ typeof provider.readiness !== "function") {
396
+ throw new Error(`extension factory returned a decision provider "${provider.id}" whose readiness is not a function`);
397
+ }
398
+ if (provider.credentialEnv !== undefined &&
399
+ (typeof provider.credentialEnv !== "string" ||
400
+ !DECISION_CREDENTIAL_ENV.test(provider.credentialEnv))) {
401
+ throw new Error(`extension factory returned a decision provider "${provider.id}" with an invalid credentialEnv ${JSON.stringify(provider.credentialEnv)} (must match ${DECISION_CREDENTIAL_ENV.source})`);
402
+ }
403
+ }
404
+ return value;
405
+ }
235
406
  function validateCurationTargets(value) {
236
407
  if (value === undefined)
237
408
  return undefined;
@@ -260,6 +431,29 @@ function validateCurationTargets(value) {
260
431
  typeof target.maintain !== "function") {
261
432
  throw new Error(`extension factory returned a curation target "${name}" whose maintain is not a function`);
262
433
  }
434
+ // C5: readTools — an array of non-empty strings DISJOINT from
435
+ // tools (a read tool is never wrapped; a name on both lists is a
436
+ // contradiction). The registered-by-the-declaring-extension check
437
+ // lives in the load loop (no registry here).
438
+ const readTools = target?.readTools;
439
+ if (readTools !== undefined) {
440
+ if (!Array.isArray(readTools) ||
441
+ readTools.some((tool) => typeof tool !== "string" || tool.length === 0)) {
442
+ throw new Error(`extension factory returned a curation target "${name}" whose readTools is not an array of non-empty strings`);
443
+ }
444
+ const overlap = readTools.find((tool) => tools.includes(tool));
445
+ if (overlap !== undefined) {
446
+ throw new Error(`extension factory returned a curation target "${name}" whose readTools names "${overlap}" — a tool is either listed (wrapped) or read (never wrapped), never both`);
447
+ }
448
+ }
449
+ if (target?.wantsReview !== undefined &&
450
+ typeof target.wantsReview !== "function") {
451
+ throw new Error(`extension factory returned a curation target "${name}" whose wantsReview is not a function`);
452
+ }
453
+ if (target?.reviewed !== undefined &&
454
+ typeof target.reviewed !== "function") {
455
+ throw new Error(`extension factory returned a curation target "${name}" whose reviewed is not a function`);
456
+ }
263
457
  targets.push(entry);
264
458
  }
265
459
  return targets;
@@ -488,6 +682,18 @@ function validateSurfaces(value) {
488
682
  typeof def.sharedData !== "boolean") {
489
683
  throw new Error(`extension factory returned a surface "${def.id}" whose sharedData is not a boolean`);
490
684
  }
685
+ if (def?.perConversation !== undefined &&
686
+ typeof def.perConversation !== "boolean") {
687
+ throw new Error(`extension factory returned a surface "${def.id}" whose perConversation is not a boolean`);
688
+ }
689
+ // The flag is a PANEL property: a view cannot be attached to a
690
+ // conversation, so declaring it there is a declaration the host
691
+ // would silently ignore — fail loud. (The settings-section list is
692
+ // validated separately by `validateSettingsSections`, which does
693
+ // not read this flag.)
694
+ if (def?.perConversation !== undefined && expected !== "panel") {
695
+ throw new Error(`extension factory returned a surface "${def.id}" declares perConversation but is not a panel`);
696
+ }
491
697
  if (def?.actions !== undefined) {
492
698
  if (typeof def.actions !== "object" ||
493
699
  def.actions === null ||
@@ -1179,6 +1385,9 @@ async function resolvedFromFactory(factory, hostContext) {
1179
1385
  throw new Error("extension factory returned a perUserTools field that is not a boolean");
1180
1386
  }
1181
1387
  const providers = validateProviders(candidate.providers);
1388
+ // Decision providers (warden Task 6): validated with the other
1389
+ // contribution fields, BEFORE the contributor-only check reads them.
1390
+ const decisionProviders = validateDecisionProviders(candidate.decisionProviders);
1182
1391
  const loginProviders = validateLoginProviders(candidate.loginProviders);
1183
1392
  const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
1184
1393
  const jobs = validateJobs(candidate.jobs);
@@ -1207,7 +1416,8 @@ async function resolvedFromFactory(factory, hostContext) {
1207
1416
  loginProviders === undefined &&
1208
1417
  webuiBundles === undefined &&
1209
1418
  channels === undefined &&
1210
- userServices === undefined) {
1419
+ userServices === undefined &&
1420
+ decisionProviders === undefined) {
1211
1421
  return undefined;
1212
1422
  }
1213
1423
  if (typeof candidate.name !== "string" || candidate.name.length === 0) {
@@ -1241,6 +1451,11 @@ async function resolvedFromFactory(factory, hostContext) {
1241
1451
  validateKinds(candidate.ambientKinds);
1242
1452
  if (kindsProblem !== null)
1243
1453
  throw new Error(kindsProblem);
1454
+ // Interactive tools (ask-user Task 3, spec §6): an array of non-empty
1455
+ // strings, each naming a tool the extension's OWN `extension.tools`
1456
+ // registers — hiding a tool the extension never registered is a
1457
+ // declaration bug that must quarantine, never silently no-op.
1458
+ const interactiveTools = validateInteractiveTools(candidate.interactiveTools, extension);
1244
1459
  // The settings-change hook (U4 §13.2): present-but-not-a-function is
1245
1460
  // the same contract violation class as the lifecycle fields — an
1246
1461
  // extension that THINKS it observes settings changes must not
@@ -1255,6 +1470,15 @@ async function resolvedFromFactory(factory, hostContext) {
1255
1470
  // qualified-id duplicate check runs in the load loop where the slug
1256
1471
  // and the already-claimed ids are known).
1257
1472
  const curationTargets = validateCurationTargets(candidate.curationTargets);
1473
+ // Notice actions (notices Task 3): a plain object of name → handler,
1474
+ // every name grammar-checked; a malformed entry quarantines.
1475
+ const noticeActions = validateNoticeActions(candidate.noticeActions);
1476
+ // Published hooks (knowledge C7): validated HERE (before the slug
1477
+ // exists — ownership and shadowing are registry concerns, never
1478
+ // load-failures); the declarations attribute to the slug at
1479
+ // collection. A malformed entry or a schema that fails to compile
1480
+ // quarantines the extension.
1481
+ const publishedHooks = validatePublishedHooks(candidate.publishedHooks);
1258
1482
  return {
1259
1483
  ...(candidate.perUserTools === undefined
1260
1484
  ? {}
@@ -1291,11 +1515,15 @@ async function resolvedFromFactory(factory, hostContext) {
1291
1515
  ...(candidate.interactionKinds === undefined
1292
1516
  ? {}
1293
1517
  : { interactionKinds: candidate.interactionKinds }),
1518
+ ...(interactiveTools === undefined ? {} : { interactiveTools }),
1294
1519
  ...(candidate.ambientKinds === undefined
1295
1520
  ? {}
1296
1521
  : { ambientKinds: candidate.ambientKinds }),
1297
1522
  ...(workers === undefined ? {} : { workers }),
1298
1523
  ...(curationTargets === undefined ? {} : { curationTargets }),
1524
+ ...(noticeActions === undefined ? {} : { noticeActions }),
1525
+ ...(publishedHooks === undefined ? {} : { publishedHooks }),
1526
+ ...(decisionProviders === undefined ? {} : { decisionProviders }),
1299
1527
  ...(providers === undefined ? {} : { providers }),
1300
1528
  ...(loginProviders === undefined ? {} : { loginProviders }),
1301
1529
  ...(webuiBundles === undefined ? {} : { webuiBundles }),
@@ -1494,10 +1722,22 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1494
1722
  // entry per loaded extension whose result carried them — boot folds
1495
1723
  // these into the ONE CurationRegistry (src/curation/targets.ts).
1496
1724
  const curationBindings = [];
1725
+ const noticeActionBindings = [];
1726
+ // Published hooks (knowledge C7): flat declarations, in load order,
1727
+ // slug-attributed with the built-in flag — boot folds them into the
1728
+ // registry AFTER `loadExtensions` returns (ownership: configured before
1729
+ // built-ins, first per name wins — the registry's own ordering rule).
1730
+ const publishedHookDeclarations = [];
1731
+ // Decision-provider declarations (warden Task 6), slug-attributed —
1732
+ // boot folds them into the ONE DecisionRegistry.
1733
+ const decisionProviderDeclarations = [];
1497
1734
  const userServiceBindings = [];
1498
1735
  // Jobs Task 3: declared jobs, attributed to the extension slug (the
1499
1736
  // `ext:<slug>:<name>` id's namespace).
1500
1737
  const jobBindings = [];
1738
+ // Interactive tool names (ask-user Task 3): the flat union over loaded
1739
+ // extensions (boot folds it into the one ReadonlySet on OpenedHarness).
1740
+ const interactiveTools = [];
1501
1741
  // HTTP routes (W5 Task 2), attributed to the slug like the job bindings.
1502
1742
  const routeBindings = [];
1503
1743
  // Slash commands (W5 Task 3), attributed the same way.
@@ -1786,6 +2026,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1786
2026
  if (unregistered !== undefined) {
1787
2027
  throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
1788
2028
  }
2029
+ // C5: the same check for readTools (never wrapped, never fed
2030
+ // to the wrap feed — only registered, so the served filter and
2031
+ // the review gate see a real tool).
2032
+ const unregisteredRead = (target.readTools ?? []).find((tool) => !registered.has(tool));
2033
+ if (unregisteredRead !== undefined) {
2034
+ throw new Error(`extension "${slug}" declares curation target "${target.name}" listing read tool "${unregisteredRead}" it does not register`);
2035
+ }
1789
2036
  // A tool names ONE target (A8): two targets — across any
1790
2037
  // extensions — listing the same tool leave `byTool`
1791
2038
  // first-wins arbitrary and the shadowed target's undos
@@ -1895,6 +2142,32 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1895
2142
  workers: resolved.workers,
1896
2143
  });
1897
2144
  }
2145
+ if (resolved.noticeActions !== undefined) {
2146
+ noticeActionBindings.push({
2147
+ extension: slug,
2148
+ actions: resolved.noticeActions,
2149
+ });
2150
+ }
2151
+ // Published hooks (knowledge C7): collected AFTER the install
2152
+ // — a quarantined extension never reaches here, so every
2153
+ // declaration in the list comes from a LOADED extension.
2154
+ for (const hook of resolved.publishedHooks ?? []) {
2155
+ publishedHookDeclarations.push({
2156
+ extension: slug,
2157
+ builtin: builtin === true,
2158
+ hook,
2159
+ });
2160
+ }
2161
+ // Decision providers (warden Task 6): collected AFTER the install
2162
+ // — a quarantined extension never reaches here, so every
2163
+ // declaration comes from a LOADED extension. The slug (not the
2164
+ // declared contribution id) is the attribution.
2165
+ for (const contribution of resolved.decisionProviders ?? []) {
2166
+ decisionProviderDeclarations.push({
2167
+ extension: slug,
2168
+ contribution,
2169
+ });
2170
+ }
1898
2171
  if (resolved.curationTargets !== undefined) {
1899
2172
  curationBindings.push({
1900
2173
  extension: slug,
@@ -1910,6 +2183,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1910
2183
  if (resolved.jobs !== undefined) {
1911
2184
  jobBindings.push({ extension: slug, jobs: resolved.jobs });
1912
2185
  }
2186
+ // Interactive tools (ask-user Task 3): collected AFTER the
2187
+ // install, slug-attributed — a quarantined extension never
2188
+ // reaches here, so the union boot folds comes from LOADED
2189
+ // extensions only.
2190
+ if (resolved.interactiveTools !== undefined) {
2191
+ interactiveTools.push(...resolved.interactiveTools);
2192
+ }
1913
2193
  if (resolved.userServices !== undefined) {
1914
2194
  userServiceBindings.push({
1915
2195
  extension: slug,
@@ -2025,7 +2305,11 @@ export async function loadExtensions(specifiers, registry, options = {}) {
2025
2305
  lifecycleBindings,
2026
2306
  workerBindings,
2027
2307
  curationBindings,
2308
+ noticeActionBindings,
2309
+ publishedHookDeclarations,
2310
+ decisionProviderDeclarations,
2028
2311
  jobBindings,
2312
+ interactiveTools,
2029
2313
  routeBindings,
2030
2314
  commandBindings,
2031
2315
  notifierBindings,
@@ -84,6 +84,13 @@ export const HOST_RUNTIME_MODULES = [
84
84
  "@earendil-works/pi-ai",
85
85
  "@earendil-works/pi-durable",
86
86
  "@earendil-works/chord",
87
+ // @sigrun/ext-codemode imports CodemodeSandbox at RUNTIME. Core does not
88
+ // depend on pi-codemode (it arrives transitively via pi-durable), so a
89
+ // packages-dir install of the extension could not resolve it — the
90
+ // extension quarantined with "Cannot find package
91
+ // '@earendil-works/pi-codemode'". Linked like the other Earendil host
92
+ // modules so the extension resolves the copy the host actually runs.
93
+ "@earendil-works/pi-codemode",
87
94
  "typebox",
88
95
  // The shared workspace is host-shipped too (wire + access contracts
89
96
  // every extension family type against; @sigrun/recall is the first