@pie-players/pie-assessment-toolkit 0.3.30 → 0.3.32

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 (179) hide show
  1. package/README.md +289 -5
  2. package/dist/components/ItemToolBar.custom-element.js +165 -92
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +604 -107
  4. package/dist/components/SectionToolBar.custom-element.js +0 -36
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/policy/core/PolicySource.d.ts +64 -0
  9. package/dist/policy/core/PolicySource.d.ts.map +1 -0
  10. package/dist/policy/core/PolicySource.js +17 -0
  11. package/dist/policy/core/PolicySource.js.map +1 -0
  12. package/dist/policy/core/ToolPolicyEngine.d.ts +123 -0
  13. package/dist/policy/core/ToolPolicyEngine.d.ts.map +1 -0
  14. package/dist/policy/core/ToolPolicyEngine.js +217 -0
  15. package/dist/policy/core/ToolPolicyEngine.js.map +1 -0
  16. package/dist/policy/core/compose-decision.d.ts +37 -0
  17. package/dist/policy/core/compose-decision.d.ts.map +1 -0
  18. package/dist/policy/core/compose-decision.js +332 -0
  19. package/dist/policy/core/compose-decision.js.map +1 -0
  20. package/dist/policy/core/decision-types.d.ts +142 -0
  21. package/dist/policy/core/decision-types.d.ts.map +1 -0
  22. package/dist/policy/core/decision-types.js +13 -0
  23. package/dist/policy/core/decision-types.js.map +1 -0
  24. package/dist/policy/core/engine-context.d.ts +30 -0
  25. package/dist/policy/core/engine-context.d.ts.map +1 -0
  26. package/dist/policy/core/engine-context.js +28 -0
  27. package/dist/policy/core/engine-context.js.map +1 -0
  28. package/dist/policy/core/policy-source-tag.d.ts +32 -0
  29. package/dist/policy/core/policy-source-tag.d.ts.map +1 -0
  30. package/dist/policy/core/policy-source-tag.js +29 -0
  31. package/dist/policy/core/policy-source-tag.js.map +1 -0
  32. package/dist/policy/core/provenance.d.ts +163 -0
  33. package/dist/policy/core/provenance.d.ts.map +1 -0
  34. package/dist/policy/core/provenance.js +232 -0
  35. package/dist/policy/core/provenance.js.map +1 -0
  36. package/dist/policy/core/qti-inputs.d.ts +67 -0
  37. package/dist/policy/core/qti-inputs.d.ts.map +1 -0
  38. package/dist/policy/core/qti-inputs.js +139 -0
  39. package/dist/policy/core/qti-inputs.js.map +1 -0
  40. package/dist/policy/engine.d.ts +28 -0
  41. package/dist/policy/engine.d.ts.map +1 -0
  42. package/dist/policy/engine.js +24 -0
  43. package/dist/policy/engine.js.map +1 -0
  44. package/dist/policy/internal.d.ts +26 -0
  45. package/dist/policy/internal.d.ts.map +1 -0
  46. package/dist/policy/internal.js +26 -0
  47. package/dist/policy/internal.js.map +1 -0
  48. package/dist/policy/sources/QtiPolicySource.d.ts +146 -0
  49. package/dist/policy/sources/QtiPolicySource.d.ts.map +1 -0
  50. package/dist/policy/sources/QtiPolicySource.js +270 -0
  51. package/dist/policy/sources/QtiPolicySource.js.map +1 -0
  52. package/dist/runtime/SectionRuntimeEngine.d.ts +145 -0
  53. package/dist/runtime/SectionRuntimeEngine.d.ts.map +1 -1
  54. package/dist/runtime/SectionRuntimeEngine.js +164 -3
  55. package/dist/runtime/SectionRuntimeEngine.js.map +1 -1
  56. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +144 -0
  57. package/dist/runtime/adapter/SectionEngineAdapter.d.ts.map +1 -0
  58. package/dist/runtime/adapter/SectionEngineAdapter.js +183 -0
  59. package/dist/runtime/adapter/SectionEngineAdapter.js.map +1 -0
  60. package/dist/runtime/adapter/coordinator-bridge.d.ts +93 -0
  61. package/dist/runtime/adapter/coordinator-bridge.d.ts.map +1 -0
  62. package/dist/runtime/adapter/coordinator-bridge.js +127 -0
  63. package/dist/runtime/adapter/coordinator-bridge.js.map +1 -0
  64. package/dist/runtime/adapter/dom-event-bridge.d.ts +63 -0
  65. package/dist/runtime/adapter/dom-event-bridge.d.ts.map +1 -0
  66. package/dist/runtime/adapter/dom-event-bridge.js +95 -0
  67. package/dist/runtime/adapter/dom-event-bridge.js.map +1 -0
  68. package/dist/runtime/adapter/framework-error-bridge.d.ts +37 -0
  69. package/dist/runtime/adapter/framework-error-bridge.d.ts.map +1 -0
  70. package/dist/runtime/adapter/framework-error-bridge.js +47 -0
  71. package/dist/runtime/adapter/framework-error-bridge.js.map +1 -0
  72. package/dist/runtime/adapter/instrumentation-bridge.d.ts +32 -0
  73. package/dist/runtime/adapter/instrumentation-bridge.d.ts.map +1 -0
  74. package/dist/runtime/adapter/instrumentation-bridge.js +39 -0
  75. package/dist/runtime/adapter/instrumentation-bridge.js.map +1 -0
  76. package/dist/runtime/adapter/subscriber-fanout.d.ts +35 -0
  77. package/dist/runtime/adapter/subscriber-fanout.d.ts.map +1 -0
  78. package/dist/runtime/adapter/subscriber-fanout.js +60 -0
  79. package/dist/runtime/adapter/subscriber-fanout.js.map +1 -0
  80. package/dist/runtime/core/SectionEngineCore.d.ts +52 -0
  81. package/dist/runtime/core/SectionEngineCore.d.ts.map +1 -0
  82. package/dist/runtime/core/SectionEngineCore.js +81 -0
  83. package/dist/runtime/core/SectionEngineCore.js.map +1 -0
  84. package/dist/runtime/core/cohort.d.ts +40 -0
  85. package/dist/runtime/core/cohort.d.ts.map +1 -0
  86. package/dist/runtime/core/cohort.js +49 -0
  87. package/dist/runtime/core/cohort.js.map +1 -0
  88. package/dist/runtime/core/engine-input.d.ts +114 -0
  89. package/dist/runtime/core/engine-input.d.ts.map +1 -0
  90. package/dist/runtime/core/engine-input.js +19 -0
  91. package/dist/runtime/core/engine-input.js.map +1 -0
  92. package/dist/runtime/core/engine-output.d.ts +62 -0
  93. package/dist/runtime/core/engine-output.d.ts.map +1 -0
  94. package/dist/runtime/core/engine-output.js +27 -0
  95. package/dist/runtime/core/engine-output.js.map +1 -0
  96. package/dist/runtime/core/engine-readiness.d.ts +49 -0
  97. package/dist/runtime/core/engine-readiness.d.ts.map +1 -0
  98. package/dist/runtime/core/engine-readiness.js +47 -0
  99. package/dist/runtime/core/engine-readiness.js.map +1 -0
  100. package/dist/runtime/core/engine-resolver.d.ts +172 -0
  101. package/dist/runtime/core/engine-resolver.d.ts.map +1 -0
  102. package/dist/runtime/core/engine-resolver.js +170 -0
  103. package/dist/runtime/core/engine-resolver.js.map +1 -0
  104. package/dist/runtime/core/engine-stage-derivation.d.ts +19 -0
  105. package/dist/runtime/core/engine-stage-derivation.d.ts.map +1 -0
  106. package/dist/runtime/core/engine-stage-derivation.js +30 -0
  107. package/dist/runtime/core/engine-stage-derivation.js.map +1 -0
  108. package/dist/runtime/core/engine-state.d.ts +83 -0
  109. package/dist/runtime/core/engine-state.d.ts.map +1 -0
  110. package/dist/runtime/core/engine-state.js +49 -0
  111. package/dist/runtime/core/engine-state.js.map +1 -0
  112. package/dist/runtime/core/engine-transition.d.ts +49 -0
  113. package/dist/runtime/core/engine-transition.d.ts.map +1 -0
  114. package/dist/runtime/core/engine-transition.js +261 -0
  115. package/dist/runtime/core/engine-transition.js.map +1 -0
  116. package/dist/runtime/engine-context.d.ts +40 -0
  117. package/dist/runtime/engine-context.d.ts.map +1 -0
  118. package/dist/runtime/engine-context.js +32 -0
  119. package/dist/runtime/engine-context.js.map +1 -0
  120. package/dist/runtime/engine.d.ts +24 -0
  121. package/dist/runtime/engine.d.ts.map +1 -0
  122. package/dist/runtime/engine.js +24 -0
  123. package/dist/runtime/engine.js.map +1 -0
  124. package/dist/runtime/internal.d.ts +44 -0
  125. package/dist/runtime/internal.d.ts.map +1 -0
  126. package/dist/runtime/internal.js +46 -0
  127. package/dist/runtime/internal.js.map +1 -0
  128. package/dist/runtime/runtime-id.d.ts +8 -0
  129. package/dist/runtime/runtime-id.d.ts.map +1 -0
  130. package/dist/runtime/runtime-id.js +10 -0
  131. package/dist/runtime/runtime-id.js.map +1 -0
  132. package/dist/runtime/section-runtime-engine-host-context.d.ts +74 -0
  133. package/dist/runtime/section-runtime-engine-host-context.d.ts.map +1 -0
  134. package/dist/runtime/section-runtime-engine-host-context.js +96 -0
  135. package/dist/runtime/section-runtime-engine-host-context.js.map +1 -0
  136. package/dist/runtime/stage-emit-gate.d.ts +93 -0
  137. package/dist/runtime/stage-emit-gate.d.ts.map +1 -0
  138. package/dist/runtime/stage-emit-gate.js +83 -0
  139. package/dist/runtime/stage-emit-gate.js.map +1 -0
  140. package/dist/services/ToolkitCoordinator.d.ts +280 -7
  141. package/dist/services/ToolkitCoordinator.d.ts.map +1 -1
  142. package/dist/services/ToolkitCoordinator.js +412 -32
  143. package/dist/services/ToolkitCoordinator.js.map +1 -1
  144. package/dist/services/framework-error-bus.d.ts +73 -0
  145. package/dist/services/framework-error-bus.d.ts.map +1 -0
  146. package/dist/services/framework-error-bus.js +68 -0
  147. package/dist/services/framework-error-bus.js.map +1 -0
  148. package/dist/services/framework-error.d.ts +36 -1
  149. package/dist/services/framework-error.d.ts.map +1 -1
  150. package/dist/services/framework-error.js +37 -0
  151. package/dist/services/framework-error.js.map +1 -1
  152. package/dist/services/interfaces.d.ts +78 -0
  153. package/dist/services/interfaces.d.ts.map +1 -1
  154. package/dist/services/interfaces.js.map +1 -1
  155. package/dist/services/section-controller-types.d.ts +20 -0
  156. package/dist/services/section-controller-types.d.ts.map +1 -1
  157. package/dist/services/tools-config-normalizer.d.ts +23 -0
  158. package/dist/services/tools-config-normalizer.d.ts.map +1 -1
  159. package/dist/services/tools-config-normalizer.js +13 -1
  160. package/dist/services/tools-config-normalizer.js.map +1 -1
  161. package/dist/tools/client.d.ts +0 -1
  162. package/dist/tools/client.d.ts.map +1 -1
  163. package/dist/tools/client.js +0 -2
  164. package/dist/tools/client.js.map +1 -1
  165. package/dist/tools/types.d.ts +0 -26
  166. package/dist/tools/types.d.ts.map +1 -1
  167. package/package.json +23 -7
  168. package/dist/runtime/runtime-event-guards.d.ts +0 -2
  169. package/dist/runtime/runtime-event-guards.d.ts.map +0 -1
  170. package/dist/runtime/runtime-event-guards.js +0 -4
  171. package/dist/runtime/runtime-event-guards.js.map +0 -1
  172. package/dist/tools/tool-coordinator.d.ts +0 -72
  173. package/dist/tools/tool-coordinator.d.ts.map +0 -1
  174. package/dist/tools/tool-coordinator.js +0 -194
  175. package/dist/tools/tool-coordinator.js.map +0 -1
  176. package/dist/types/events.d.ts +0 -157
  177. package/dist/types/events.d.ts.map +0 -1
  178. package/dist/types/events.js +0 -11
  179. package/dist/types/events.js.map +0 -1
@@ -13,10 +13,12 @@
13
13
  *
14
14
  * Part of PIE Assessment Toolkit.
15
15
  */
16
- import { normalizeToolsConfig, resolveToolsForLevel, } from "./tools-config-normalizer.js";
16
+ import { normalizeToolsConfig, } from "./tools-config-normalizer.js";
17
17
  import { normalizeAndValidateToolsConfig, normalizeToolConfigStrictness, } from "./tool-config-validation.js";
18
18
  import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
19
19
  import { ElementToolStateStore } from "./ElementToolStateStore.js";
20
+ import { frameworkErrorFromCoordinatorContext, } from "./framework-error.js";
21
+ import { FrameworkErrorBus, } from "./framework-error-bus.js";
20
22
  import { HighlightCoordinator } from "./HighlightCoordinator.js";
21
23
  import { ToolCoordinator } from "./ToolCoordinator.js";
22
24
  import { TTSService } from "./TTSService.js";
@@ -24,6 +26,8 @@ import { BrowserTTSProvider } from "./tts/browser-provider.js";
24
26
  import { buildRuntimeTTSConfig, resolveTTSBackend, } from "./tts-runtime-config.js";
25
27
  import { ToolProviderRegistry } from "./tool-providers/index.js";
26
28
  import { createPackagedToolRegistry } from "./createDefaultToolRegistry.js";
29
+ import { ToolPolicyEngine, } from "../policy/engine.js";
30
+ import { resolveDefaultQtiEnforcement } from "../policy/internal.js";
27
31
  const SECTION_ITEM_EVENT_TYPES = [
28
32
  "item-selected",
29
33
  "item-session-data-changed",
@@ -98,7 +102,51 @@ export class ToolkitCoordinator {
98
102
  sectionEventListenerIds = new WeakMap();
99
103
  sectionEventSubscriptions = new Map();
100
104
  telemetryListeners = new Set();
105
+ frameworkErrorBus;
106
+ ownsFrameworkErrorBus;
101
107
  nextSectionEventListenerId = 1;
108
+ /**
109
+ * Unified Tool Policy Engine. Owned by the coordinator and lives
110
+ * for the lifetime of the coordinator instance — there is no
111
+ * explicit teardown path today; the engine and its listener set
112
+ * are reclaimed by GC when the coordinator becomes unreachable.
113
+ * Subscribers attached via {@link onPolicyChange} must therefore
114
+ * detach via the unsubscribe function the engine returns; do not
115
+ * rely on a `disposed` event being emitted on coordinator teardown.
116
+ *
117
+ * Hosts read decisions via {@link decideToolPolicy} or subscribe
118
+ * to changes via {@link onPolicyChange}. The legacy
119
+ * `resolveToolsForLevel` / `PnpToolResolver` paths still coexist
120
+ * until the upcoming compat-removal sweep deletes them.
121
+ */
122
+ policyEngine;
123
+ /**
124
+ * Host-set override for QTI enforcement. `null` (the default) means
125
+ * "auto" — the coordinator infers the effective mode from the
126
+ * QTI inputs the bound `AssessmentEntity` and `AssessmentItemRef`
127
+ * actually carry (see {@link resolveEffectiveQtiEnforcement}).
128
+ * `"on"` / `"off"` are explicit host opt-in / opt-out and stick
129
+ * across subsequent assessment / item swaps until the host clears
130
+ * the override by calling `setQtiEnforcement(null)`.
131
+ */
132
+ qtiEnforcementOverride = null;
133
+ /**
134
+ * Last assessment passed to {@link updateAssessment}. Read by
135
+ * {@link resolveEffectiveQtiEnforcement} to compute auto-mode.
136
+ * The engine's own copy is the canonical record for decisions;
137
+ * this mirror exists only so the auto-mode helper does not need
138
+ * to round-trip through {@link policyEngine}'s frozen snapshot.
139
+ */
140
+ boundAssessment = null;
141
+ /**
142
+ * Last item reference passed to {@link updateCurrentItemRef}.
143
+ * Mirrored alongside {@link boundAssessment} so
144
+ * {@link resolveEffectiveQtiEnforcement} can detect item-level
145
+ * QTI inputs (`requiredTools` / `restrictedTools` /
146
+ * `toolParameters`) without round-tripping through the engine's
147
+ * frozen snapshot.
148
+ */
149
+ boundCurrentItemRef = null;
102
150
  /** Callback for floating tools changes */
103
151
  floatingToolsChangeCallback = null;
104
152
  static resolveConfig(config) {
@@ -148,6 +196,13 @@ export class ToolkitCoordinator {
148
196
  this.toolRegistry = resolvedConfig.toolRegistry ?? createPackagedToolRegistry();
149
197
  this.hooks = resolvedConfig.hooks ?? {};
150
198
  this.lazyInit = config.lazyInit === true;
199
+ // Use the host-provided framework-error bus if one was passed
200
+ // (typical when embedded inside <pie-assessment-toolkit>, so
201
+ // pre-coordinator failures from the CE flow through the same
202
+ // fan-out as coordinator failures). Otherwise own a private one.
203
+ this.frameworkErrorBus = config.frameworkErrorBus ?? new FrameworkErrorBus();
204
+ this.ownsFrameworkErrorBus = !config.frameworkErrorBus;
205
+ this.subscribeFrameworkErrorHookAdapters();
151
206
  // Initialize all services
152
207
  this.toolCoordinator = new ToolCoordinator();
153
208
  this.highlightCoordinator = new HighlightCoordinator();
@@ -159,6 +214,24 @@ export class ToolkitCoordinator {
159
214
  // Initialize TTS service based on config
160
215
  this.ttsService = new TTSService();
161
216
  this.setupStatePersistenceHooks();
217
+ // M8 PR 2 — construct the unified ToolPolicyEngine seeded with
218
+ // the validated tools config. QTI inputs (`assessment`,
219
+ // `currentItemRef`) start `null`; `qtiEnforcement` is resolved
220
+ // through {@link resolveEffectiveQtiEnforcement}, which flips
221
+ // to `"on"` only once the bound assessment or item carries
222
+ // actual QTI material (PR 4). Hosts that only consume the
223
+ // engine for placement/policy gating get the pre-PR-2 behavior
224
+ // bit-for-bit.
225
+ this.policyEngine = new ToolPolicyEngine({
226
+ toolRegistry: this.toolRegistry,
227
+ contextId: `toolkit-coordinator:${this.assessmentId}`,
228
+ inputs: {
229
+ tools: this.config.tools,
230
+ assessment: null,
231
+ currentItemRef: null,
232
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
233
+ },
234
+ });
162
235
  if (!this.lazyInit) {
163
236
  void this.waitUntilReady().catch((err) => {
164
237
  console.error("[ToolkitCoordinator] Failed eager initialization:", err);
@@ -166,6 +239,50 @@ export class ToolkitCoordinator {
166
239
  });
167
240
  }
168
241
  }
242
+ /**
243
+ * Subscribe the canonical `onFrameworkError` hook adapter to the
244
+ * framework-error bus.
245
+ *
246
+ * Called once from the constructor. The adapter inspects the live
247
+ * `this.hooks` reference, so a hook added later via {@link setHooks}
248
+ * is picked up automatically without re-subscribing.
249
+ */
250
+ subscribeFrameworkErrorHookAdapters() {
251
+ this.frameworkErrorBus.subscribeFrameworkErrors((model) => {
252
+ const hook = this.hooks.onFrameworkError;
253
+ if (!hook)
254
+ return;
255
+ try {
256
+ hook(model);
257
+ }
258
+ catch (hookError) {
259
+ console.warn("[ToolkitCoordinator] onFrameworkError hook failed:", hookError);
260
+ }
261
+ });
262
+ }
263
+ /**
264
+ * Emit a telemetry event to `onTelemetry` hook + all `subscribeTelemetry`
265
+ * listeners.
266
+ *
267
+ * **Naming convention.** Event names MUST be prefixed at the call site,
268
+ * not auto-decorated here. The convention is:
269
+ *
270
+ * - `pie-toolkit-*` for toolkit lifecycle (state load, providers,
271
+ * coordinator readiness, section-controller register/dispose, TTS
272
+ * bring-up, tool-config updates, etc.).
273
+ * - `pie-tool-*` for individual tool events (provider init lifecycle,
274
+ * provider backend calls, tool-specific telemetry forwarded from
275
+ * `__pieTelemetry`).
276
+ * - `pie-section-*` is reserved for section-player layout events
277
+ * surfaced via `attachInstrumentationEventBridge` and is not emitted
278
+ * from this method directly.
279
+ *
280
+ * Anything that does not fit a documented prefix is a deliberate decision
281
+ * to be raised in code review, not a fallback. There is no "auto prefix"
282
+ * here on purpose: it keeps every emit-site honest about which namespace
283
+ * it owns, and lets `subscribeTelemetry` consumers compare against
284
+ * documented strings without per-consumer normalization.
285
+ */
169
286
  async emitTelemetry(eventName, payload) {
170
287
  try {
171
288
  await this.hooks.onTelemetry?.(eventName, payload);
@@ -197,13 +314,40 @@ export class ToolkitCoordinator {
197
314
  }
198
315
  }
199
316
  handleError(error, context) {
200
- const normalized = error instanceof Error ? error : new Error(String(error));
201
- try {
202
- this.hooks.onError?.(normalized, context);
203
- }
204
- catch (hookError) {
205
- console.warn("[ToolkitCoordinator] onError hook failed:", hookError);
206
- }
317
+ const model = frameworkErrorFromCoordinatorContext({
318
+ error,
319
+ context,
320
+ });
321
+ this.frameworkErrorBus.reportFrameworkError(model);
322
+ }
323
+ /**
324
+ * Subscribe to framework-error events emitted by this coordinator.
325
+ *
326
+ * See `ToolkitCoordinatorApi.subscribeFrameworkErrors` for the
327
+ * contract. The bus is shared with the canonical
328
+ * `onFrameworkError` lifecycle hook, so a listener registered here
329
+ * sees the same fan-out the hook sees.
330
+ */
331
+ subscribeFrameworkErrors(listener) {
332
+ return this.frameworkErrorBus.subscribeFrameworkErrors(listener);
333
+ }
334
+ /**
335
+ * Report a framework-error model directly into this coordinator's bus.
336
+ *
337
+ * Used by embedding hosts (e.g. `<pie-assessment-toolkit>`) that
338
+ * pre-construct their own framework-error model in a path that does
339
+ * not go through `handleError` — for example, `runtime-init` failures
340
+ * raised before any coordinator phase, or `tool-config` failures
341
+ * synthesized from validation diagnostics.
342
+ *
343
+ * Hosts that already share their bus via the constructor's
344
+ * `frameworkErrorBus` config field do not need to call this; their
345
+ * own `bus.reportFrameworkError(model)` is observed here.
346
+ *
347
+ * @internal
348
+ */
349
+ reportFrameworkError(model) {
350
+ this.frameworkErrorBus.reportFrameworkError(model);
207
351
  }
208
352
  getSectionControllerMapKey(key) {
209
353
  return `${key.assessmentId}::${key.sectionId}::${key.attemptId || ""}`;
@@ -287,7 +431,7 @@ export class ToolkitCoordinator {
287
431
  this.elementToolStateStore.loadState(state);
288
432
  }
289
433
  this.stateLoaded = true;
290
- await this.emitTelemetry("tool-state-loaded", {
434
+ await this.emitTelemetry("pie-toolkit-tool-state-loaded", {
291
435
  hasState: Boolean(state),
292
436
  });
293
437
  }
@@ -382,7 +526,7 @@ export class ToolkitCoordinator {
382
526
  providerName: config.provider.providerName,
383
527
  };
384
528
  await this.hooks.onProviderRegistered?.(providerId, meta);
385
- await this.emitTelemetry("provider-registered", {
529
+ await this.emitTelemetry("pie-toolkit-provider-registered", {
386
530
  providerId,
387
531
  providerName: config.provider.providerName,
388
532
  });
@@ -390,10 +534,6 @@ export class ToolkitCoordinator {
390
534
  catch (err) {
391
535
  console.warn(`[ToolkitCoordinator] Failed to register provider "${providerId}":`, err);
392
536
  this.handleError(err, { phase: "provider-register", providerId });
393
- this.hooks.onProviderError?.(providerId, err, {
394
- phase: "provider-register",
395
- providerId,
396
- });
397
537
  }
398
538
  }
399
539
  async ensureProviderReady(providerId) {
@@ -410,15 +550,11 @@ export class ToolkitCoordinator {
410
550
  await this.hooks.onProviderInitStart?.(providerId, meta);
411
551
  await this.toolProviderRegistry.initialize(providerId);
412
552
  await this.hooks.onProviderReady?.(providerId, meta);
413
- await this.emitTelemetry("provider-ready", { providerId });
553
+ await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
414
554
  return provider;
415
555
  }
416
556
  catch (err) {
417
557
  const error = err instanceof Error ? err : new Error(String(err));
418
- this.hooks.onProviderError?.(providerId, error, {
419
- phase: "provider-init",
420
- providerId,
421
- });
422
558
  this.handleError(error, { phase: "provider-init", providerId });
423
559
  throw error;
424
560
  }
@@ -473,6 +609,21 @@ export class ToolkitCoordinator {
473
609
  return;
474
610
  args.listener(event);
475
611
  });
612
+ // Replay `content-loaded` events first so a late subscriber observes
613
+ // the same ordering a live subscriber would have seen (per-renderable
614
+ // loads, then the aggregate `section-loading-complete`). Symmetric
615
+ // with `section-loading-complete` replay below; without this, a
616
+ // consumer that attaches after a cohort transition (e.g. a fresh
617
+ // section controller created on navigation) silently misses every
618
+ // `content-loaded` event for items that finished loading before the
619
+ // subscription attached. See PIE-512 for the consumer-observable
620
+ // regression.
621
+ const contentLoadedReplays = this.buildContentLoadedReplayEvents(controller);
622
+ for (const event of contentLoadedReplays) {
623
+ if (shouldDeliverEvent(event)) {
624
+ args.listener(event);
625
+ }
626
+ }
476
627
  const replayEvent = this.buildLoadingCompleteReplayEvent(controller);
477
628
  if (replayEvent && shouldDeliverEvent(replayEvent)) {
478
629
  args.listener(replayEvent);
@@ -543,6 +694,53 @@ export class ToolkitCoordinator {
543
694
  }
544
695
  return itemIds;
545
696
  }
697
+ /**
698
+ * Build replay `content-loaded` events for renderables the controller has
699
+ * already finished loading.
700
+ *
701
+ * Strict by design: only renderables explicitly reported in
702
+ * `runtimeState.loadedRenderables` are replayed. Synthetic test harnesses
703
+ * that omit the field (or controllers from older revisions that did not
704
+ * populate it) get an empty replay set rather than a fabricated one — that
705
+ * preserves the contract that replays mirror events that actually fired.
706
+ *
707
+ * Order follows registration order from
708
+ * `SectionController.collectLoadedRenderableSnapshot`, which mirrors the
709
+ * order live `content-loaded` events were emitted in.
710
+ */
711
+ buildContentLoadedReplayEvents(controller) {
712
+ const runtimeState = controller.getRuntimeState?.();
713
+ const loadedRenderables = runtimeState?.loadedRenderables;
714
+ if (!Array.isArray(loadedRenderables) || loadedRenderables.length === 0) {
715
+ return [];
716
+ }
717
+ const currentItemIndex = typeof runtimeState?.currentItemIndex === "number" &&
718
+ Number.isFinite(runtimeState.currentItemIndex)
719
+ ? runtimeState.currentItemIndex
720
+ : 0;
721
+ const timestamp = Date.now();
722
+ const events = [];
723
+ for (const renderable of loadedRenderables) {
724
+ if (!renderable)
725
+ continue;
726
+ const itemId = renderable.itemId;
727
+ const canonicalItemId = typeof renderable.canonicalItemId === "string" &&
728
+ renderable.canonicalItemId
729
+ ? renderable.canonicalItemId
730
+ : itemId;
731
+ if (typeof itemId !== "string" || !itemId)
732
+ continue;
733
+ events.push({
734
+ type: "content-loaded",
735
+ contentKind: renderable.contentKind ?? "unknown",
736
+ itemId,
737
+ canonicalItemId,
738
+ currentItemIndex,
739
+ timestamp,
740
+ });
741
+ }
742
+ return events;
743
+ }
546
744
  buildLoadingCompleteReplayEvent(controller) {
547
745
  const runtimeState = controller.getRuntimeState?.();
548
746
  if (runtimeState?.loadingComplete !== true)
@@ -751,7 +949,7 @@ export class ToolkitCoordinator {
751
949
  controller: args.controller,
752
950
  });
753
951
  await this.hooks.onSectionControllerReady?.(args.context, args.controller);
754
- await this.emitTelemetry("section-controller-ready", {
952
+ await this.emitTelemetry("pie-toolkit-section-controller-ready", {
755
953
  assessmentId: args.key.assessmentId,
756
954
  sectionId: args.key.sectionId,
757
955
  attemptId: args.key.attemptId,
@@ -821,7 +1019,7 @@ export class ToolkitCoordinator {
821
1019
  }
822
1020
  await args.controller.dispose?.();
823
1021
  await this.hooks.onSectionControllerDispose?.(args.context, args.controller);
824
- await this.emitTelemetry("section-controller-disposed", {
1022
+ await this.emitTelemetry("pie-toolkit-section-controller-disposed", {
825
1023
  assessmentId: args.key.assessmentId,
826
1024
  sectionId: args.key.sectionId,
827
1025
  attemptId: args.key.attemptId,
@@ -873,7 +1071,7 @@ export class ToolkitCoordinator {
873
1071
  backend: resolvedBackend,
874
1072
  },
875
1073
  });
876
- await this.emitTelemetry("tts-init-start", {
1074
+ await this.emitTelemetry("pie-toolkit-tts-init-start", {
877
1075
  backend: resolvedBackend,
878
1076
  });
879
1077
  await this.emitTelemetry("pie-tool-init-start", {
@@ -887,7 +1085,7 @@ export class ToolkitCoordinator {
887
1085
  const ttsProvider = await this.ensureProviderReady("tts");
888
1086
  const providerInstance = await ttsProvider.createInstance();
889
1087
  await this.initializeTTSService(providerInstance, runtimeTTSConfig);
890
- await this.emitTelemetry("tts-init-success", {
1088
+ await this.emitTelemetry("pie-toolkit-tts-init-success", {
891
1089
  provider: "registry",
892
1090
  });
893
1091
  await this.emitTelemetry("pie-tool-init-success", {
@@ -926,7 +1124,7 @@ export class ToolkitCoordinator {
926
1124
  const provider = new BrowserTTSProvider();
927
1125
  try {
928
1126
  await this.initializeTTSService(provider, runtimeTTSConfig);
929
- await this.emitTelemetry("tts-init-success", {
1127
+ await this.emitTelemetry("pie-toolkit-tts-init-success", {
930
1128
  provider: "browser-fallback",
931
1129
  });
932
1130
  await this.emitTelemetry("pie-tool-init-success", {
@@ -938,9 +1136,8 @@ export class ToolkitCoordinator {
938
1136
  }
939
1137
  catch (error) {
940
1138
  const normalized = error instanceof Error ? error : new Error(String(error));
941
- this.hooks.onTTSError?.(normalized, { phase: "tts-init" });
942
1139
  this.handleError(normalized, { phase: "tts-init" });
943
- await this.emitTelemetry("tts-init-error", {
1140
+ await this.emitTelemetry("pie-toolkit-tts-init-error", {
944
1141
  message: normalized.message,
945
1142
  });
946
1143
  await this.emitTelemetry("pie-tool-init-error", {
@@ -1013,7 +1210,7 @@ export class ToolkitCoordinator {
1013
1210
  }
1014
1211
  });
1015
1212
  const voicesAfterWait = synth.getVoices();
1016
- await this.emitTelemetry("tts-browser-voices-ready", {
1213
+ await this.emitTelemetry("pie-toolkit-tts-browser-voices-ready", {
1017
1214
  voiceCount: voicesAfterWait.length,
1018
1215
  timedOut: voicesAfterWait.length === 0,
1019
1216
  });
@@ -1055,7 +1252,7 @@ export class ToolkitCoordinator {
1055
1252
  if (!this.coordinatorReadyNotified) {
1056
1253
  this.coordinatorReadyNotified = true;
1057
1254
  await this.hooks.onCoordinatorReady?.(this);
1058
- await this.emitTelemetry("coordinator-ready", {
1255
+ await this.emitTelemetry("pie-toolkit-coordinator-ready", {
1059
1256
  assessmentId: this.assessmentId,
1060
1257
  });
1061
1258
  }
@@ -1155,7 +1352,14 @@ export class ToolkitCoordinator {
1155
1352
  toolRegistry: this.toolRegistry,
1156
1353
  });
1157
1354
  this.config.tools = validated.config;
1158
- void this.emitTelemetry("tool-config-updated", { toolId });
1355
+ // M8 PR 2 — keep the policy engine's tools input in lockstep
1356
+ // with the validated coordinator config. The engine emits an
1357
+ // `inputs` change event so subscribers (e.g. PR 3 toolbars) can
1358
+ // re-decide without us managing a parallel pub/sub.
1359
+ this.policyEngine.updateInputs({
1360
+ tools: this.config.tools,
1361
+ });
1362
+ void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
1159
1363
  // Apply configuration changes to services
1160
1364
  this._applyToolConfigChange(toolId, updates);
1161
1365
  }
@@ -1186,20 +1390,57 @@ export class ToolkitCoordinator {
1186
1390
  toolRegistry: this.toolRegistry,
1187
1391
  });
1188
1392
  this.config.tools = validated.config;
1393
+ // M8 PR 2 — keep the policy engine in lockstep with the
1394
+ // floating-tools placement update. See `updateToolConfig` for
1395
+ // the same pattern.
1396
+ this.policyEngine.updateInputs({
1397
+ tools: this.config.tools,
1398
+ });
1189
1399
  // Notify listener of change
1190
1400
  if (this.floatingToolsChangeCallback) {
1191
1401
  this.floatingToolsChangeCallback(toolIds);
1192
1402
  }
1193
1403
  }
1194
1404
  /**
1195
- * Get currently enabled floating tools.
1405
+ * Get currently enabled floating tools (section-level placement).
1406
+ *
1407
+ * @returns Array of enabled tool IDs.
1408
+ *
1409
+ * @remarks
1410
+ * As of M8 PR 2 this routes through the {@link ToolPolicyEngine}
1411
+ * rather than the legacy `resolveToolsForLevel(...)` shim. The two
1412
+ * paths agree on `placement → policy.allowed → policy.blocked`,
1413
+ * but the engine additionally enforces:
1414
+ *
1415
+ * - **Provider veto** — `tools.providers[id].enabled === false`
1416
+ * removes the tool from the visible set. The legacy resolver
1417
+ * ignored this flag for floating tools.
1418
+ * - **QTI gates** — when `qtiEnforcement` is `"on"` (set by host
1419
+ * via {@link setQtiEnforcement}, or auto-promoted when the
1420
+ * bound `AssessmentEntity` / current `AssessmentItemRef`
1421
+ * carries QTI 6-level precedence material — see
1422
+ * {@link resolveEffectiveQtiEnforcement}) **and an assessment
1423
+ * is bound**, the QTI 6-level precedence (district block →
1424
+ * test-admin override → item restriction/requirement →
1425
+ * district requirement → PNP supports / prohibitions) is
1426
+ * applied. `qtiEnforcement: "on"` without a bound assessment
1427
+ * is a no-op for QTI gating.
1428
+ * - **Custom `PolicySource`s** registered via
1429
+ * {@link registerPolicySource}.
1430
+ *
1431
+ * Under the default no-assessment, no-override, no-provider-veto
1432
+ * configuration the result still matches `resolveToolsForLevel(...)`
1433
+ * exactly — a parity asserted by the integration tests in
1434
+ * `tests/policy/coordinator-integration.test.ts`. Once PR 5 deletes
1435
+ * the legacy resolver this method becomes the canonical surface.
1196
1436
  *
1197
- * @returns Array of enabled tool IDs
1437
+ * Consumers that need the full decision (provenance, diagnostics)
1438
+ * should call {@link decideToolPolicy} instead.
1198
1439
  */
1199
1440
  getFloatingTools() {
1200
1441
  if (!this.config.tools)
1201
1442
  return [];
1202
- return resolveToolsForLevel(this.config.tools, "section");
1443
+ return this.policyEngine.getVisibleToolIds("section", "*");
1203
1444
  }
1204
1445
  /**
1205
1446
  * Set a callback to be notified when floating tools change.
@@ -1216,6 +1457,145 @@ export class ToolkitCoordinator {
1216
1457
  this.floatingToolsChangeCallback = null;
1217
1458
  };
1218
1459
  }
1460
+ // ----------------------------------------------------------------
1461
+ // M8 PR 2 — Tool Policy Engine surface
1462
+ //
1463
+ // The methods below are additive in PR 2: hosts can already drive
1464
+ // the unified ToolPolicyEngine, but the existing toolbar / section-
1465
+ // player render paths still consume the legacy `resolveToolsForLevel`
1466
+ // / `PnpToolResolver` chain. PR 3 switches consumers; PR 5 deletes
1467
+ // the legacy path. See `.cursor/plans/m8-implementation-plan.md`.
1468
+ // ----------------------------------------------------------------
1469
+ /**
1470
+ * Resolve the visible tool set for a given placement level + scope.
1471
+ *
1472
+ * Thin shim over the owned tool-policy engine. Hosts that need
1473
+ * richer outputs (provenance, diagnostics) read this directly;
1474
+ * hosts that only need the section-level tool IDs can call
1475
+ * {@link getFloatingTools} instead.
1476
+ */
1477
+ decideToolPolicy(request) {
1478
+ return this.policyEngine.decide(request);
1479
+ }
1480
+ /**
1481
+ * Subscribe to policy-engine change events. Fires whenever the
1482
+ * coordinator's bound inputs change (`updateToolConfig`,
1483
+ * `updateFloatingTools`, `updateAssessment`, `updateCurrentItemRef`,
1484
+ * `setQtiEnforcement`) or a custom `PolicySource` is registered /
1485
+ * removed via {@link registerPolicySource}.
1486
+ *
1487
+ * The listener receives a `ToolPolicyChangeEvent` with the event
1488
+ * `reason` and a frozen snapshot of the engine inputs. Listeners
1489
+ * that want the new visible tool set should call
1490
+ * {@link decideToolPolicy} with their level / scope.
1491
+ *
1492
+ * Note: the engine itself can also emit `reason: "disposed"`, but
1493
+ * the coordinator does not dispose its engine on teardown today,
1494
+ * so subscribers attached via this method will not observe that
1495
+ * reason. Detach via the returned unsubscribe function instead of
1496
+ * relying on a `disposed` event.
1497
+ */
1498
+ onPolicyChange(listener) {
1499
+ return this.policyEngine.onPolicyChange(listener);
1500
+ }
1501
+ /**
1502
+ * Bind (or clear) the active QTI assessment for policy decisions.
1503
+ *
1504
+ * Under auto-mode (no host override via {@link setQtiEnforcement}),
1505
+ * the coordinator promotes the engine to `qtiEnforcement: "on"`
1506
+ * iff the assessment carries any QTI 6-level precedence material
1507
+ * (`personalNeedsProfile`, `settings.districtPolicy`,
1508
+ * `settings.testAdministration`) or the currently-bound item ref
1509
+ * carries item-level QTI inputs. A bare assessment record (just
1510
+ * `id` / `name`, no PNP, no settings) keeps `"off"`.
1511
+ *
1512
+ * The host override set via {@link setQtiEnforcement} is sticky
1513
+ * across assessment swaps; calling with `null` clears the binding
1514
+ * and re-runs the auto-mode helper.
1515
+ */
1516
+ updateAssessment(assessment) {
1517
+ this.boundAssessment = assessment;
1518
+ this.policyEngine.updateInputs({
1519
+ assessment,
1520
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
1521
+ });
1522
+ }
1523
+ /**
1524
+ * Bind (or clear) the current item reference for policy decisions.
1525
+ *
1526
+ * Used by item-level QTI gates (item `requiredTools` /
1527
+ * `restrictedTools` / `toolParameters`). Item-level QTI material
1528
+ * also feeds {@link resolveEffectiveQtiEnforcement} — navigating
1529
+ * to an item with QTI settings can flip auto-mode to `"on"` even
1530
+ * when the parent assessment carries no QTI block of its own.
1531
+ */
1532
+ updateCurrentItemRef(itemRef) {
1533
+ this.boundCurrentItemRef = itemRef;
1534
+ this.policyEngine.updateInputs({
1535
+ currentItemRef: itemRef,
1536
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
1537
+ });
1538
+ }
1539
+ /**
1540
+ * Override the auto-mode QTI enforcement decision.
1541
+ *
1542
+ * Pass `"on"` to force QTI enforcement even when no QTI inputs
1543
+ * are bound (useful for tests / fixtures). Pass `"off"` to opt
1544
+ * out even when QTI inputs are present. Pass `null` to clear the
1545
+ * override and return to auto-mode — `"on"` iff
1546
+ * {@link resolveDefaultQtiEnforcement} reports any QTI material
1547
+ * on the bound assessment or current item, otherwise `"off"`.
1548
+ */
1549
+ setQtiEnforcement(mode) {
1550
+ this.qtiEnforcementOverride = mode;
1551
+ this.policyEngine.updateInputs({
1552
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
1553
+ });
1554
+ }
1555
+ /**
1556
+ * Get the policy engine inputs currently driving decisions. Useful
1557
+ * for debugging / instrumentation; do not mutate.
1558
+ */
1559
+ getPolicyInputs() {
1560
+ return this.policyEngine.getInputs();
1561
+ }
1562
+ /**
1563
+ * Register a custom {@link PolicySource} with the owned policy
1564
+ * engine. The source participates in every subsequent
1565
+ * {@link decideToolPolicy} call until disposed (the returned
1566
+ * function detaches and emits a `policy-source-removed` event).
1567
+ *
1568
+ * Delegates verbatim to
1569
+ * {@link ToolPolicyEngine.registerPolicySource} — see that
1570
+ * method for the full contract (event ordering, idempotency of
1571
+ * the returned dispose function, and how registered sources
1572
+ * compose with the built-in QTI source).
1573
+ */
1574
+ registerPolicySource(source) {
1575
+ return this.policyEngine.registerPolicySource(source);
1576
+ }
1577
+ /**
1578
+ * Compute the effective `qtiEnforcement` mode given the explicit
1579
+ * host override and the auto-mode helper.
1580
+ *
1581
+ * Auto-mode (no override) defers to
1582
+ * {@link resolveDefaultQtiEnforcement}, which returns `"on"`
1583
+ * exactly when the bound assessment or current item ref carries
1584
+ * QTI 6-level precedence material (PNP, district policy, test
1585
+ * administration, item-level required/restricted/parameters), and
1586
+ * `"off"` otherwise. Hosts that bind a bare assessment record
1587
+ * (just `id` / `name`) therefore keep the legacy floating-tools
1588
+ * behavior — QTI gates engage the moment QTI material is present.
1589
+ */
1590
+ resolveEffectiveQtiEnforcement() {
1591
+ if (this.qtiEnforcementOverride !== null) {
1592
+ return this.qtiEnforcementOverride;
1593
+ }
1594
+ return resolveDefaultQtiEnforcement({
1595
+ assessment: this.boundAssessment,
1596
+ currentItemRef: this.boundCurrentItemRef,
1597
+ });
1598
+ }
1219
1599
  /**
1220
1600
  * Apply tool configuration changes to underlying services.
1221
1601
  * Called after updateToolConfig().