@pie-players/pie-assessment-toolkit 0.3.29 → 0.3.31

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 (199) hide show
  1. package/README.md +313 -5
  2. package/dist/components/ItemToolBar.custom-element.js +362 -176
  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 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/policy/core/PolicySource.d.ts +64 -0
  10. package/dist/policy/core/PolicySource.d.ts.map +1 -0
  11. package/dist/policy/core/PolicySource.js +17 -0
  12. package/dist/policy/core/PolicySource.js.map +1 -0
  13. package/dist/policy/core/ToolPolicyEngine.d.ts +123 -0
  14. package/dist/policy/core/ToolPolicyEngine.d.ts.map +1 -0
  15. package/dist/policy/core/ToolPolicyEngine.js +217 -0
  16. package/dist/policy/core/ToolPolicyEngine.js.map +1 -0
  17. package/dist/policy/core/compose-decision.d.ts +37 -0
  18. package/dist/policy/core/compose-decision.d.ts.map +1 -0
  19. package/dist/policy/core/compose-decision.js +332 -0
  20. package/dist/policy/core/compose-decision.js.map +1 -0
  21. package/dist/policy/core/decision-types.d.ts +142 -0
  22. package/dist/policy/core/decision-types.d.ts.map +1 -0
  23. package/dist/policy/core/decision-types.js +13 -0
  24. package/dist/policy/core/decision-types.js.map +1 -0
  25. package/dist/policy/core/engine-context.d.ts +30 -0
  26. package/dist/policy/core/engine-context.d.ts.map +1 -0
  27. package/dist/policy/core/engine-context.js +28 -0
  28. package/dist/policy/core/engine-context.js.map +1 -0
  29. package/dist/policy/core/policy-source-tag.d.ts +32 -0
  30. package/dist/policy/core/policy-source-tag.d.ts.map +1 -0
  31. package/dist/policy/core/policy-source-tag.js +29 -0
  32. package/dist/policy/core/policy-source-tag.js.map +1 -0
  33. package/dist/policy/core/provenance.d.ts +163 -0
  34. package/dist/policy/core/provenance.d.ts.map +1 -0
  35. package/dist/policy/core/provenance.js +232 -0
  36. package/dist/policy/core/provenance.js.map +1 -0
  37. package/dist/policy/core/qti-inputs.d.ts +67 -0
  38. package/dist/policy/core/qti-inputs.d.ts.map +1 -0
  39. package/dist/policy/core/qti-inputs.js +139 -0
  40. package/dist/policy/core/qti-inputs.js.map +1 -0
  41. package/dist/policy/engine.d.ts +28 -0
  42. package/dist/policy/engine.d.ts.map +1 -0
  43. package/dist/policy/engine.js +24 -0
  44. package/dist/policy/engine.js.map +1 -0
  45. package/dist/policy/internal.d.ts +26 -0
  46. package/dist/policy/internal.d.ts.map +1 -0
  47. package/dist/policy/internal.js +26 -0
  48. package/dist/policy/internal.js.map +1 -0
  49. package/dist/policy/sources/QtiPolicySource.d.ts +146 -0
  50. package/dist/policy/sources/QtiPolicySource.d.ts.map +1 -0
  51. package/dist/policy/sources/QtiPolicySource.js +270 -0
  52. package/dist/policy/sources/QtiPolicySource.js.map +1 -0
  53. package/dist/runtime/SectionRuntimeEngine.d.ts +145 -0
  54. package/dist/runtime/SectionRuntimeEngine.d.ts.map +1 -1
  55. package/dist/runtime/SectionRuntimeEngine.js +164 -3
  56. package/dist/runtime/SectionRuntimeEngine.js.map +1 -1
  57. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +144 -0
  58. package/dist/runtime/adapter/SectionEngineAdapter.d.ts.map +1 -0
  59. package/dist/runtime/adapter/SectionEngineAdapter.js +183 -0
  60. package/dist/runtime/adapter/SectionEngineAdapter.js.map +1 -0
  61. package/dist/runtime/adapter/coordinator-bridge.d.ts +93 -0
  62. package/dist/runtime/adapter/coordinator-bridge.d.ts.map +1 -0
  63. package/dist/runtime/adapter/coordinator-bridge.js +127 -0
  64. package/dist/runtime/adapter/coordinator-bridge.js.map +1 -0
  65. package/dist/runtime/adapter/dom-event-bridge.d.ts +63 -0
  66. package/dist/runtime/adapter/dom-event-bridge.d.ts.map +1 -0
  67. package/dist/runtime/adapter/dom-event-bridge.js +95 -0
  68. package/dist/runtime/adapter/dom-event-bridge.js.map +1 -0
  69. package/dist/runtime/adapter/framework-error-bridge.d.ts +37 -0
  70. package/dist/runtime/adapter/framework-error-bridge.d.ts.map +1 -0
  71. package/dist/runtime/adapter/framework-error-bridge.js +47 -0
  72. package/dist/runtime/adapter/framework-error-bridge.js.map +1 -0
  73. package/dist/runtime/adapter/instrumentation-bridge.d.ts +32 -0
  74. package/dist/runtime/adapter/instrumentation-bridge.d.ts.map +1 -0
  75. package/dist/runtime/adapter/instrumentation-bridge.js +39 -0
  76. package/dist/runtime/adapter/instrumentation-bridge.js.map +1 -0
  77. package/dist/runtime/adapter/subscriber-fanout.d.ts +35 -0
  78. package/dist/runtime/adapter/subscriber-fanout.d.ts.map +1 -0
  79. package/dist/runtime/adapter/subscriber-fanout.js +60 -0
  80. package/dist/runtime/adapter/subscriber-fanout.js.map +1 -0
  81. package/dist/runtime/core/SectionEngineCore.d.ts +52 -0
  82. package/dist/runtime/core/SectionEngineCore.d.ts.map +1 -0
  83. package/dist/runtime/core/SectionEngineCore.js +81 -0
  84. package/dist/runtime/core/SectionEngineCore.js.map +1 -0
  85. package/dist/runtime/core/cohort.d.ts +40 -0
  86. package/dist/runtime/core/cohort.d.ts.map +1 -0
  87. package/dist/runtime/core/cohort.js +49 -0
  88. package/dist/runtime/core/cohort.js.map +1 -0
  89. package/dist/runtime/core/engine-input.d.ts +114 -0
  90. package/dist/runtime/core/engine-input.d.ts.map +1 -0
  91. package/dist/runtime/core/engine-input.js +19 -0
  92. package/dist/runtime/core/engine-input.js.map +1 -0
  93. package/dist/runtime/core/engine-output.d.ts +62 -0
  94. package/dist/runtime/core/engine-output.d.ts.map +1 -0
  95. package/dist/runtime/core/engine-output.js +27 -0
  96. package/dist/runtime/core/engine-output.js.map +1 -0
  97. package/dist/runtime/core/engine-readiness.d.ts +49 -0
  98. package/dist/runtime/core/engine-readiness.d.ts.map +1 -0
  99. package/dist/runtime/core/engine-readiness.js +47 -0
  100. package/dist/runtime/core/engine-readiness.js.map +1 -0
  101. package/dist/runtime/core/engine-resolver.d.ts +172 -0
  102. package/dist/runtime/core/engine-resolver.d.ts.map +1 -0
  103. package/dist/runtime/core/engine-resolver.js +170 -0
  104. package/dist/runtime/core/engine-resolver.js.map +1 -0
  105. package/dist/runtime/core/engine-stage-derivation.d.ts +19 -0
  106. package/dist/runtime/core/engine-stage-derivation.d.ts.map +1 -0
  107. package/dist/runtime/core/engine-stage-derivation.js +30 -0
  108. package/dist/runtime/core/engine-stage-derivation.js.map +1 -0
  109. package/dist/runtime/core/engine-state.d.ts +83 -0
  110. package/dist/runtime/core/engine-state.d.ts.map +1 -0
  111. package/dist/runtime/core/engine-state.js +49 -0
  112. package/dist/runtime/core/engine-state.js.map +1 -0
  113. package/dist/runtime/core/engine-transition.d.ts +49 -0
  114. package/dist/runtime/core/engine-transition.d.ts.map +1 -0
  115. package/dist/runtime/core/engine-transition.js +261 -0
  116. package/dist/runtime/core/engine-transition.js.map +1 -0
  117. package/dist/runtime/engine-context.d.ts +40 -0
  118. package/dist/runtime/engine-context.d.ts.map +1 -0
  119. package/dist/runtime/engine-context.js +32 -0
  120. package/dist/runtime/engine-context.js.map +1 -0
  121. package/dist/runtime/engine.d.ts +24 -0
  122. package/dist/runtime/engine.d.ts.map +1 -0
  123. package/dist/runtime/engine.js +24 -0
  124. package/dist/runtime/engine.js.map +1 -0
  125. package/dist/runtime/internal.d.ts +44 -0
  126. package/dist/runtime/internal.d.ts.map +1 -0
  127. package/dist/runtime/internal.js +46 -0
  128. package/dist/runtime/internal.js.map +1 -0
  129. package/dist/runtime/runtime-id.d.ts +8 -0
  130. package/dist/runtime/runtime-id.d.ts.map +1 -0
  131. package/dist/runtime/runtime-id.js +10 -0
  132. package/dist/runtime/runtime-id.js.map +1 -0
  133. package/dist/runtime/section-runtime-engine-host-context.d.ts +74 -0
  134. package/dist/runtime/section-runtime-engine-host-context.d.ts.map +1 -0
  135. package/dist/runtime/section-runtime-engine-host-context.js +96 -0
  136. package/dist/runtime/section-runtime-engine-host-context.js.map +1 -0
  137. package/dist/runtime/stage-emit-gate.d.ts +93 -0
  138. package/dist/runtime/stage-emit-gate.d.ts.map +1 -0
  139. package/dist/runtime/stage-emit-gate.js +83 -0
  140. package/dist/runtime/stage-emit-gate.js.map +1 -0
  141. package/dist/services/SSMLExtractor.d.ts.map +1 -1
  142. package/dist/services/SSMLExtractor.js +189 -2
  143. package/dist/services/SSMLExtractor.js.map +1 -1
  144. package/dist/services/TTSService.d.ts +7 -0
  145. package/dist/services/TTSService.d.ts.map +1 -1
  146. package/dist/services/TTSService.js +15 -0
  147. package/dist/services/TTSService.js.map +1 -1
  148. package/dist/services/ToolRegistry.d.ts.map +1 -1
  149. package/dist/services/ToolRegistry.js +30 -0
  150. package/dist/services/ToolRegistry.js.map +1 -1
  151. package/dist/services/ToolkitCoordinator.d.ts +264 -6
  152. package/dist/services/ToolkitCoordinator.d.ts.map +1 -1
  153. package/dist/services/ToolkitCoordinator.js +350 -32
  154. package/dist/services/ToolkitCoordinator.js.map +1 -1
  155. package/dist/services/framework-error-bus.d.ts +73 -0
  156. package/dist/services/framework-error-bus.d.ts.map +1 -0
  157. package/dist/services/framework-error-bus.js +68 -0
  158. package/dist/services/framework-error-bus.js.map +1 -0
  159. package/dist/services/framework-error.d.ts +36 -1
  160. package/dist/services/framework-error.d.ts.map +1 -1
  161. package/dist/services/framework-error.js +37 -0
  162. package/dist/services/framework-error.js.map +1 -1
  163. package/dist/services/interfaces.d.ts +85 -0
  164. package/dist/services/interfaces.d.ts.map +1 -1
  165. package/dist/services/interfaces.js.map +1 -1
  166. package/dist/services/tool-config-validation.js +1 -1
  167. package/dist/services/tool-config-validation.js.map +1 -1
  168. package/dist/services/tools-config-normalizer.d.ts +24 -1
  169. package/dist/services/tools-config-normalizer.d.ts.map +1 -1
  170. package/dist/services/tools-config-normalizer.js +13 -1
  171. package/dist/services/tools-config-normalizer.js.map +1 -1
  172. package/dist/services/tts-control-events.d.ts +14 -0
  173. package/dist/services/tts-control-events.d.ts.map +1 -0
  174. package/dist/services/tts-control-events.js +8 -0
  175. package/dist/services/tts-control-events.js.map +1 -0
  176. package/dist/tools/calculators/desmos-provider.js +18 -0
  177. package/dist/tools/calculators/desmos-provider.js.map +1 -1
  178. package/dist/tools/client.d.ts +0 -1
  179. package/dist/tools/client.d.ts.map +1 -1
  180. package/dist/tools/client.js +0 -2
  181. package/dist/tools/client.js.map +1 -1
  182. package/dist/tools/default-tool-module-loaders.d.ts.map +1 -1
  183. package/dist/tools/default-tool-module-loaders.js +2 -0
  184. package/dist/tools/default-tool-module-loaders.js.map +1 -1
  185. package/dist/tools/types.d.ts +0 -26
  186. package/dist/tools/types.d.ts.map +1 -1
  187. package/package.json +25 -8
  188. package/dist/runtime/runtime-event-guards.d.ts +0 -2
  189. package/dist/runtime/runtime-event-guards.d.ts.map +0 -1
  190. package/dist/runtime/runtime-event-guards.js +0 -4
  191. package/dist/runtime/runtime-event-guards.js.map +0 -1
  192. package/dist/tools/tool-coordinator.d.ts +0 -72
  193. package/dist/tools/tool-coordinator.d.ts.map +0 -1
  194. package/dist/tools/tool-coordinator.js +0 -194
  195. package/dist/tools/tool-coordinator.js.map +0 -1
  196. package/dist/types/events.d.ts +0 -157
  197. package/dist/types/events.d.ts.map +0 -1
  198. package/dist/types/events.js +0 -11
  199. 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
  }
@@ -751,7 +887,7 @@ export class ToolkitCoordinator {
751
887
  controller: args.controller,
752
888
  });
753
889
  await this.hooks.onSectionControllerReady?.(args.context, args.controller);
754
- await this.emitTelemetry("section-controller-ready", {
890
+ await this.emitTelemetry("pie-toolkit-section-controller-ready", {
755
891
  assessmentId: args.key.assessmentId,
756
892
  sectionId: args.key.sectionId,
757
893
  attemptId: args.key.attemptId,
@@ -821,7 +957,7 @@ export class ToolkitCoordinator {
821
957
  }
822
958
  await args.controller.dispose?.();
823
959
  await this.hooks.onSectionControllerDispose?.(args.context, args.controller);
824
- await this.emitTelemetry("section-controller-disposed", {
960
+ await this.emitTelemetry("pie-toolkit-section-controller-disposed", {
825
961
  assessmentId: args.key.assessmentId,
826
962
  sectionId: args.key.sectionId,
827
963
  attemptId: args.key.attemptId,
@@ -873,7 +1009,7 @@ export class ToolkitCoordinator {
873
1009
  backend: resolvedBackend,
874
1010
  },
875
1011
  });
876
- await this.emitTelemetry("tts-init-start", {
1012
+ await this.emitTelemetry("pie-toolkit-tts-init-start", {
877
1013
  backend: resolvedBackend,
878
1014
  });
879
1015
  await this.emitTelemetry("pie-tool-init-start", {
@@ -887,7 +1023,7 @@ export class ToolkitCoordinator {
887
1023
  const ttsProvider = await this.ensureProviderReady("tts");
888
1024
  const providerInstance = await ttsProvider.createInstance();
889
1025
  await this.initializeTTSService(providerInstance, runtimeTTSConfig);
890
- await this.emitTelemetry("tts-init-success", {
1026
+ await this.emitTelemetry("pie-toolkit-tts-init-success", {
891
1027
  provider: "registry",
892
1028
  });
893
1029
  await this.emitTelemetry("pie-tool-init-success", {
@@ -926,7 +1062,7 @@ export class ToolkitCoordinator {
926
1062
  const provider = new BrowserTTSProvider();
927
1063
  try {
928
1064
  await this.initializeTTSService(provider, runtimeTTSConfig);
929
- await this.emitTelemetry("tts-init-success", {
1065
+ await this.emitTelemetry("pie-toolkit-tts-init-success", {
930
1066
  provider: "browser-fallback",
931
1067
  });
932
1068
  await this.emitTelemetry("pie-tool-init-success", {
@@ -938,9 +1074,8 @@ export class ToolkitCoordinator {
938
1074
  }
939
1075
  catch (error) {
940
1076
  const normalized = error instanceof Error ? error : new Error(String(error));
941
- this.hooks.onTTSError?.(normalized, { phase: "tts-init" });
942
1077
  this.handleError(normalized, { phase: "tts-init" });
943
- await this.emitTelemetry("tts-init-error", {
1078
+ await this.emitTelemetry("pie-toolkit-tts-init-error", {
944
1079
  message: normalized.message,
945
1080
  });
946
1081
  await this.emitTelemetry("pie-tool-init-error", {
@@ -1013,7 +1148,7 @@ export class ToolkitCoordinator {
1013
1148
  }
1014
1149
  });
1015
1150
  const voicesAfterWait = synth.getVoices();
1016
- await this.emitTelemetry("tts-browser-voices-ready", {
1151
+ await this.emitTelemetry("pie-toolkit-tts-browser-voices-ready", {
1017
1152
  voiceCount: voicesAfterWait.length,
1018
1153
  timedOut: voicesAfterWait.length === 0,
1019
1154
  });
@@ -1055,7 +1190,7 @@ export class ToolkitCoordinator {
1055
1190
  if (!this.coordinatorReadyNotified) {
1056
1191
  this.coordinatorReadyNotified = true;
1057
1192
  await this.hooks.onCoordinatorReady?.(this);
1058
- await this.emitTelemetry("coordinator-ready", {
1193
+ await this.emitTelemetry("pie-toolkit-coordinator-ready", {
1059
1194
  assessmentId: this.assessmentId,
1060
1195
  });
1061
1196
  }
@@ -1155,7 +1290,14 @@ export class ToolkitCoordinator {
1155
1290
  toolRegistry: this.toolRegistry,
1156
1291
  });
1157
1292
  this.config.tools = validated.config;
1158
- void this.emitTelemetry("tool-config-updated", { toolId });
1293
+ // M8 PR 2 — keep the policy engine's tools input in lockstep
1294
+ // with the validated coordinator config. The engine emits an
1295
+ // `inputs` change event so subscribers (e.g. PR 3 toolbars) can
1296
+ // re-decide without us managing a parallel pub/sub.
1297
+ this.policyEngine.updateInputs({
1298
+ tools: this.config.tools,
1299
+ });
1300
+ void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
1159
1301
  // Apply configuration changes to services
1160
1302
  this._applyToolConfigChange(toolId, updates);
1161
1303
  }
@@ -1186,20 +1328,57 @@ export class ToolkitCoordinator {
1186
1328
  toolRegistry: this.toolRegistry,
1187
1329
  });
1188
1330
  this.config.tools = validated.config;
1331
+ // M8 PR 2 — keep the policy engine in lockstep with the
1332
+ // floating-tools placement update. See `updateToolConfig` for
1333
+ // the same pattern.
1334
+ this.policyEngine.updateInputs({
1335
+ tools: this.config.tools,
1336
+ });
1189
1337
  // Notify listener of change
1190
1338
  if (this.floatingToolsChangeCallback) {
1191
1339
  this.floatingToolsChangeCallback(toolIds);
1192
1340
  }
1193
1341
  }
1194
1342
  /**
1195
- * Get currently enabled floating tools.
1343
+ * Get currently enabled floating tools (section-level placement).
1344
+ *
1345
+ * @returns Array of enabled tool IDs.
1346
+ *
1347
+ * @remarks
1348
+ * As of M8 PR 2 this routes through the {@link ToolPolicyEngine}
1349
+ * rather than the legacy `resolveToolsForLevel(...)` shim. The two
1350
+ * paths agree on `placement → policy.allowed → policy.blocked`,
1351
+ * but the engine additionally enforces:
1352
+ *
1353
+ * - **Provider veto** — `tools.providers[id].enabled === false`
1354
+ * removes the tool from the visible set. The legacy resolver
1355
+ * ignored this flag for floating tools.
1356
+ * - **QTI gates** — when `qtiEnforcement` is `"on"` (set by host
1357
+ * via {@link setQtiEnforcement}, or auto-promoted when the
1358
+ * bound `AssessmentEntity` / current `AssessmentItemRef`
1359
+ * carries QTI 6-level precedence material — see
1360
+ * {@link resolveEffectiveQtiEnforcement}) **and an assessment
1361
+ * is bound**, the QTI 6-level precedence (district block →
1362
+ * test-admin override → item restriction/requirement →
1363
+ * district requirement → PNP supports / prohibitions) is
1364
+ * applied. `qtiEnforcement: "on"` without a bound assessment
1365
+ * is a no-op for QTI gating.
1366
+ * - **Custom `PolicySource`s** registered via
1367
+ * {@link registerPolicySource}.
1368
+ *
1369
+ * Under the default no-assessment, no-override, no-provider-veto
1370
+ * configuration the result still matches `resolveToolsForLevel(...)`
1371
+ * exactly — a parity asserted by the integration tests in
1372
+ * `tests/policy/coordinator-integration.test.ts`. Once PR 5 deletes
1373
+ * the legacy resolver this method becomes the canonical surface.
1196
1374
  *
1197
- * @returns Array of enabled tool IDs
1375
+ * Consumers that need the full decision (provenance, diagnostics)
1376
+ * should call {@link decideToolPolicy} instead.
1198
1377
  */
1199
1378
  getFloatingTools() {
1200
1379
  if (!this.config.tools)
1201
1380
  return [];
1202
- return resolveToolsForLevel(this.config.tools, "section");
1381
+ return this.policyEngine.getVisibleToolIds("section", "*");
1203
1382
  }
1204
1383
  /**
1205
1384
  * Set a callback to be notified when floating tools change.
@@ -1216,6 +1395,145 @@ export class ToolkitCoordinator {
1216
1395
  this.floatingToolsChangeCallback = null;
1217
1396
  };
1218
1397
  }
1398
+ // ----------------------------------------------------------------
1399
+ // M8 PR 2 — Tool Policy Engine surface
1400
+ //
1401
+ // The methods below are additive in PR 2: hosts can already drive
1402
+ // the unified ToolPolicyEngine, but the existing toolbar / section-
1403
+ // player render paths still consume the legacy `resolveToolsForLevel`
1404
+ // / `PnpToolResolver` chain. PR 3 switches consumers; PR 5 deletes
1405
+ // the legacy path. See `.cursor/plans/m8-implementation-plan.md`.
1406
+ // ----------------------------------------------------------------
1407
+ /**
1408
+ * Resolve the visible tool set for a given placement level + scope.
1409
+ *
1410
+ * Thin shim over the owned tool-policy engine. Hosts that need
1411
+ * richer outputs (provenance, diagnostics) read this directly;
1412
+ * hosts that only need the section-level tool IDs can call
1413
+ * {@link getFloatingTools} instead.
1414
+ */
1415
+ decideToolPolicy(request) {
1416
+ return this.policyEngine.decide(request);
1417
+ }
1418
+ /**
1419
+ * Subscribe to policy-engine change events. Fires whenever the
1420
+ * coordinator's bound inputs change (`updateToolConfig`,
1421
+ * `updateFloatingTools`, `updateAssessment`, `updateCurrentItemRef`,
1422
+ * `setQtiEnforcement`) or a custom `PolicySource` is registered /
1423
+ * removed via {@link registerPolicySource}.
1424
+ *
1425
+ * The listener receives a `ToolPolicyChangeEvent` with the event
1426
+ * `reason` and a frozen snapshot of the engine inputs. Listeners
1427
+ * that want the new visible tool set should call
1428
+ * {@link decideToolPolicy} with their level / scope.
1429
+ *
1430
+ * Note: the engine itself can also emit `reason: "disposed"`, but
1431
+ * the coordinator does not dispose its engine on teardown today,
1432
+ * so subscribers attached via this method will not observe that
1433
+ * reason. Detach via the returned unsubscribe function instead of
1434
+ * relying on a `disposed` event.
1435
+ */
1436
+ onPolicyChange(listener) {
1437
+ return this.policyEngine.onPolicyChange(listener);
1438
+ }
1439
+ /**
1440
+ * Bind (or clear) the active QTI assessment for policy decisions.
1441
+ *
1442
+ * Under auto-mode (no host override via {@link setQtiEnforcement}),
1443
+ * the coordinator promotes the engine to `qtiEnforcement: "on"`
1444
+ * iff the assessment carries any QTI 6-level precedence material
1445
+ * (`personalNeedsProfile`, `settings.districtPolicy`,
1446
+ * `settings.testAdministration`) or the currently-bound item ref
1447
+ * carries item-level QTI inputs. A bare assessment record (just
1448
+ * `id` / `name`, no PNP, no settings) keeps `"off"`.
1449
+ *
1450
+ * The host override set via {@link setQtiEnforcement} is sticky
1451
+ * across assessment swaps; calling with `null` clears the binding
1452
+ * and re-runs the auto-mode helper.
1453
+ */
1454
+ updateAssessment(assessment) {
1455
+ this.boundAssessment = assessment;
1456
+ this.policyEngine.updateInputs({
1457
+ assessment,
1458
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
1459
+ });
1460
+ }
1461
+ /**
1462
+ * Bind (or clear) the current item reference for policy decisions.
1463
+ *
1464
+ * Used by item-level QTI gates (item `requiredTools` /
1465
+ * `restrictedTools` / `toolParameters`). Item-level QTI material
1466
+ * also feeds {@link resolveEffectiveQtiEnforcement} — navigating
1467
+ * to an item with QTI settings can flip auto-mode to `"on"` even
1468
+ * when the parent assessment carries no QTI block of its own.
1469
+ */
1470
+ updateCurrentItemRef(itemRef) {
1471
+ this.boundCurrentItemRef = itemRef;
1472
+ this.policyEngine.updateInputs({
1473
+ currentItemRef: itemRef,
1474
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
1475
+ });
1476
+ }
1477
+ /**
1478
+ * Override the auto-mode QTI enforcement decision.
1479
+ *
1480
+ * Pass `"on"` to force QTI enforcement even when no QTI inputs
1481
+ * are bound (useful for tests / fixtures). Pass `"off"` to opt
1482
+ * out even when QTI inputs are present. Pass `null` to clear the
1483
+ * override and return to auto-mode — `"on"` iff
1484
+ * {@link resolveDefaultQtiEnforcement} reports any QTI material
1485
+ * on the bound assessment or current item, otherwise `"off"`.
1486
+ */
1487
+ setQtiEnforcement(mode) {
1488
+ this.qtiEnforcementOverride = mode;
1489
+ this.policyEngine.updateInputs({
1490
+ qtiEnforcement: this.resolveEffectiveQtiEnforcement(),
1491
+ });
1492
+ }
1493
+ /**
1494
+ * Get the policy engine inputs currently driving decisions. Useful
1495
+ * for debugging / instrumentation; do not mutate.
1496
+ */
1497
+ getPolicyInputs() {
1498
+ return this.policyEngine.getInputs();
1499
+ }
1500
+ /**
1501
+ * Register a custom {@link PolicySource} with the owned policy
1502
+ * engine. The source participates in every subsequent
1503
+ * {@link decideToolPolicy} call until disposed (the returned
1504
+ * function detaches and emits a `policy-source-removed` event).
1505
+ *
1506
+ * Delegates verbatim to
1507
+ * {@link ToolPolicyEngine.registerPolicySource} — see that
1508
+ * method for the full contract (event ordering, idempotency of
1509
+ * the returned dispose function, and how registered sources
1510
+ * compose with the built-in QTI source).
1511
+ */
1512
+ registerPolicySource(source) {
1513
+ return this.policyEngine.registerPolicySource(source);
1514
+ }
1515
+ /**
1516
+ * Compute the effective `qtiEnforcement` mode given the explicit
1517
+ * host override and the auto-mode helper.
1518
+ *
1519
+ * Auto-mode (no override) defers to
1520
+ * {@link resolveDefaultQtiEnforcement}, which returns `"on"`
1521
+ * exactly when the bound assessment or current item ref carries
1522
+ * QTI 6-level precedence material (PNP, district policy, test
1523
+ * administration, item-level required/restricted/parameters), and
1524
+ * `"off"` otherwise. Hosts that bind a bare assessment record
1525
+ * (just `id` / `name`) therefore keep the legacy floating-tools
1526
+ * behavior — QTI gates engage the moment QTI material is present.
1527
+ */
1528
+ resolveEffectiveQtiEnforcement() {
1529
+ if (this.qtiEnforcementOverride !== null) {
1530
+ return this.qtiEnforcementOverride;
1531
+ }
1532
+ return resolveDefaultQtiEnforcement({
1533
+ assessment: this.boundAssessment,
1534
+ currentItemRef: this.boundCurrentItemRef,
1535
+ });
1536
+ }
1219
1537
  /**
1220
1538
  * Apply tool configuration changes to underlying services.
1221
1539
  * Called after updateToolConfig().