@pie-players/pie-assessment-toolkit 0.3.75 → 1.0.0-beta.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 (142) hide show
  1. package/README.md +449 -541
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +0 -1
  4. package/dist/attempt/TestSession.js +10 -5
  5. package/dist/components/ItemScope.custom-element.js +1 -1
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +8 -8
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/{ItemToolBar-7tt7v2xp.js → ItemToolBar-3zrc4p3d.js} +4 -4
  10. package/dist/components/chunks/{ItemToolBar-jx38w99s.js → ItemToolBar-66hcwmfw.js} +2 -2
  11. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  12. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  13. package/dist/components/chunks/{ItemToolBar-97hx60z4.js → ItemToolBar-kkj69ptr.js} +1 -1
  14. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +6 -0
  16. package/dist/index.d.ts +7 -7
  17. package/dist/index.js +4 -10
  18. package/dist/policy/core/PolicySource.d.ts +1 -1
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +46 -7
  20. package/dist/policy/core/ToolPolicyEngine.js +73 -16
  21. package/dist/policy/core/compose-decision.d.ts +3 -3
  22. package/dist/policy/core/compose-decision.js +30 -30
  23. package/dist/policy/core/decision-types.d.ts +44 -32
  24. package/dist/policy/core/feature-decision.d.ts +17 -15
  25. package/dist/policy/core/feature-decision.js +11 -13
  26. package/dist/policy/core/pnp-policy-inputs.d.ts +5 -2
  27. package/dist/policy/core/pnp-policy-inputs.js +12 -32
  28. package/dist/policy/core/provenance.d.ts +1 -1
  29. package/dist/policy/core/tool-parameters.d.ts +11 -0
  30. package/dist/policy/core/tool-parameters.js +15 -0
  31. package/dist/policy/engine.d.ts +1 -1
  32. package/dist/policy/sources/PnpPolicySource.d.ts +14 -22
  33. package/dist/policy/sources/PnpPolicySource.js +30 -28
  34. package/dist/runtime/SectionControllerBinding.d.ts +6 -2
  35. package/dist/runtime/SectionControllerBinding.js +21 -15
  36. package/dist/runtime/adapter/subscriber-fanout.d.ts +0 -1
  37. package/dist/runtime/adapter/subscriber-fanout.js +1 -4
  38. package/dist/runtime/composition-emit-scheduler.d.ts +3 -3
  39. package/dist/runtime/composition-emit-scheduler.js +1 -1
  40. package/dist/runtime/content-region.d.ts +0 -4
  41. package/dist/runtime/content-region.js +2 -2
  42. package/dist/runtime/core/SectionEngineCore.d.ts +0 -2
  43. package/dist/runtime/core/SectionEngineCore.js +0 -4
  44. package/dist/runtime/core/cohort.d.ts +0 -5
  45. package/dist/runtime/core/cohort.js +0 -9
  46. package/dist/runtime/core/engine-output.d.ts +3 -2
  47. package/dist/runtime/core/engine-transition.js +3 -2
  48. package/dist/runtime/registration-events.d.ts +11 -14
  49. package/dist/runtime/runtime-id.d.ts +1 -1
  50. package/dist/runtime/runtime-id.js +1 -1
  51. package/dist/runtime/shell-scope.d.ts +4 -4
  52. package/dist/runtime/shell-scope.js +15 -9
  53. package/dist/runtime/tool-host-contract.d.ts +0 -4
  54. package/dist/runtime/tool-host-contract.js +1 -1
  55. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  56. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  57. package/dist/services/ElementToolStateStore.d.ts +32 -61
  58. package/dist/services/ElementToolStateStore.js +37 -67
  59. package/dist/services/HighlightCoordinator.d.ts +9 -21
  60. package/dist/services/HighlightCoordinator.js +52 -80
  61. package/dist/services/RangeSerializer.d.ts +4 -35
  62. package/dist/services/RangeSerializer.js +4 -48
  63. package/dist/services/SSMLExtractor.d.ts +8 -31
  64. package/dist/services/SSMLExtractor.js +18 -40
  65. package/dist/services/TTSService.d.ts +49 -56
  66. package/dist/services/TTSService.js +308 -283
  67. package/dist/services/ToolCoordinator.d.ts +6 -49
  68. package/dist/services/ToolCoordinator.js +25 -61
  69. package/dist/services/ToolRegistry.d.ts +82 -80
  70. package/dist/services/ToolRegistry.js +91 -67
  71. package/dist/services/ToolkitCoordinator.d.ts +83 -81
  72. package/dist/services/ToolkitCoordinator.js +186 -174
  73. package/dist/services/framework-error.d.ts +30 -7
  74. package/dist/services/framework-error.js +10 -13
  75. package/dist/services/interfaces.d.ts +47 -71
  76. package/dist/services/section-controller-types.d.ts +7 -7
  77. package/dist/services/section-session-assignment.js +6 -8
  78. package/dist/services/spoken-audio-cards.d.ts +2 -4
  79. package/dist/services/spoken-audio-cards.js +2 -4
  80. package/dist/services/tool-config-validation.d.ts +5 -7
  81. package/dist/services/tool-config-validation.js +11 -26
  82. package/dist/services/tool-context.d.ts +7 -23
  83. package/dist/services/tool-context.js +99 -53
  84. package/dist/services/tool-failure.d.ts +30 -0
  85. package/dist/services/tool-failure.js +11 -0
  86. package/dist/services/tool-icons.d.ts +1 -2
  87. package/dist/services/tool-icons.js +2 -2
  88. package/dist/services/tool-instance-id.d.ts +8 -6
  89. package/dist/services/tool-instance-id.js +12 -18
  90. package/dist/services/tool-providers/TTSToolProvider.d.ts +6 -43
  91. package/dist/services/tool-providers/TTSToolProvider.js +7 -63
  92. package/dist/services/tool-providers/ToolProviderApi.d.ts +0 -7
  93. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +7 -42
  94. package/dist/services/tool-providers/ToolProviderRegistry.js +21 -63
  95. package/dist/services/tool-request.d.ts +4 -0
  96. package/dist/services/tool-request.js +7 -2
  97. package/dist/services/toolbar-decided-tools.d.ts +1 -0
  98. package/dist/services/toolbar-decided-tools.js +1 -0
  99. package/dist/services/toolbar-items.js +2 -5
  100. package/dist/services/tools-config-normalizer.d.ts +18 -9
  101. package/dist/services/tools-config-normalizer.js +25 -19
  102. package/dist/services/tts/browser-provider.d.ts +2 -5
  103. package/dist/services/tts/browser-provider.js +4 -20
  104. package/dist/services/tts/catalog-span-alignment.js +14 -7
  105. package/dist/services/tts/flat-tree.d.ts +0 -7
  106. package/dist/services/tts/flat-tree.js +2 -2
  107. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  108. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  109. package/dist/services/tts/generated-speech/index.js +1 -1
  110. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  111. package/dist/services/tts/generated-speech/types.js +1 -1
  112. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +10 -12
  113. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  114. package/dist/services/tts/math-aware-text-processing.js +10 -29
  115. package/dist/services/tts/math-speech.d.ts +1 -1
  116. package/dist/services/tts/math-speech.js +3 -0
  117. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  118. package/dist/services/tts/mathml-sanitization.js +9 -1
  119. package/dist/services/tts/provider-options.d.ts +12 -1
  120. package/dist/services/tts/ssml/spoken-text.js +1 -1
  121. package/dist/services/tts/start-failure.d.ts +0 -7
  122. package/dist/services/tts/start-failure.js +1 -1
  123. package/dist/services/tts/text-segmentation.d.ts +5 -0
  124. package/dist/services/tts/text-segmentation.js +13 -10
  125. package/dist/services/tts-runtime-config.d.ts +32 -12
  126. package/dist/services/tts-runtime-config.js +14 -20
  127. package/dist/tools/content-capability-resolution.d.ts +2 -2
  128. package/dist/tools/registration.d.ts +6 -5
  129. package/dist/tools/registration.js +10 -9
  130. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  131. package/dist/tools/registrations/toolbar-registration-helpers.js +6 -0
  132. package/dist/tools/tool-surface-host.js +1 -8
  133. package/package.json +8 -6
  134. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +0 -2
  135. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +0 -40
  136. package/dist/components/chunks/sre-engine-jd8cc08x.js +0 -1
  137. package/dist/services/ContextVariableStore.d.ts +0 -113
  138. package/dist/services/ContextVariableStore.js +0 -206
  139. package/dist/services/tts-control-events.d.ts +0 -13
  140. package/dist/services/tts-control-events.js +0 -7
  141. package/dist/utils/logger.d.ts +0 -6
  142. package/dist/utils/logger.js +0 -10
@@ -1,17 +1,25 @@
1
1
  /**
2
- * ToolkitCoordinator - Centralized Assessment Toolkit Service Management
2
+ * ToolkitCoordinator: one per assessment, the owner of every toolkit service
3
+ * (TTSService, ToolCoordinator, HighlightCoordinator, ElementToolStateStore,
4
+ * AccessibilityCatalogResolver, ToolProviderRegistry), the ToolPolicyEngine,
5
+ * the framework-error bus and the section controllers. It holds toolkit
6
+ * concerns only; navigation, timing and progress belong to the player.
3
7
  *
4
- * Orchestrates all toolkit services (TTS, tools, accessibility, state management) from a single entry point.
5
- * Provides centralized configuration for tool availability and settings.
6
- *
7
- * Key features:
8
- * - Owns all toolkit services (TTSService, ToolCoordinator, HighlightCoordinator, ElementToolStateStore, AccessibilityCatalogResolver)
9
- * - Single configuration point for tool availability and settings
10
- * - Sensible defaults for standalone usage
11
- * - Direct access for settings widgets (no player dependency)
12
- * - Clean separation: toolkit concerns only (NOT assessment state like navigation, timing, progress)
13
- *
14
- * Part of PIE Assessment Toolkit.
8
+ * Layout: the module's exported config, hook and event types come first, then
9
+ * the class, whose members run in this order:
10
+ * - config resolution and the constructor;
11
+ * - error, telemetry and tool-failure reporting, readiness listeners and the
12
+ * framework-error bus;
13
+ * - element tool-state persistence;
14
+ * - tool registry adoption and provider registration;
15
+ * - section-event subscriptions, which follow the active cohort;
16
+ * - section controller creation, publication and disposal;
17
+ * - coordinator disposal;
18
+ * - TTS bring-up and readiness;
19
+ * - tool config updates and the policy engine surface, with the tool-context
20
+ * resolvers split around it (installation before, queries after);
21
+ * - tool-open requests;
22
+ * - config-change application and TTS reconfiguration, last.
15
23
  */
16
24
  import { normalizeToolsConfig, } from "./tools-config-normalizer.js";
17
25
  import { collectToolConfigDiagnostics, normalizeToolConfigStrictness, reportToolConfigDiagnostics, } from "./tool-config-validation.js";
@@ -51,6 +59,16 @@ const mergeToolConfigUpdate = (toolId, current, updates) => {
51
59
  }
52
60
  return next;
53
61
  };
62
+ const TOOL_FAILURE_SUMMARY = {
63
+ "tool-module-load": "failed to load",
64
+ "tool-request-open": "failed to open on request",
65
+ "tool-request-host-check": "failed its toolbar's host check",
66
+ "tool-visibility": "failed its relevance check",
67
+ "tool-applicability": "failed its applicability check",
68
+ "tool-state-load": "failed to restore its state",
69
+ "tool-state-save": "failed to save its state",
70
+ "tool-playback": "failed during playback",
71
+ };
54
72
  const SECTION_ITEM_EVENT_TYPES = [
55
73
  "item-selected",
56
74
  "item-session-data-changed",
@@ -74,10 +92,8 @@ const SECTION_SCOPED_EVENT_TYPES = [
74
92
  *
75
93
  * @example
76
94
  * ```typescript
77
- * // Both are exported by @pie-players/pie-default-tool-loaders
78
- * const toolRegistry = createPackagedToolRegistry({
79
- * toolModuleLoaders: DEFAULT_TOOL_MODULE_LOADERS,
80
- * });
95
+ * // Exported by @pie-players/pie-default-tool-loaders
96
+ * const toolRegistry = createPackagedToolRegistry();
81
97
  *
82
98
  * // Create coordinator with configuration
83
99
  * const coordinator = new ToolkitCoordinator({
@@ -122,7 +138,6 @@ export class ToolkitCoordinator {
122
138
  ttsDegraded = false;
123
139
  ttsInitPromise;
124
140
  ttsReconfigurePromise;
125
- stateLoaded = false;
126
141
  /**
127
142
  * The loader ran, whether or not it returned state. A failed load is
128
143
  * reported once and the coordinator runs without saved tool state.
@@ -137,8 +152,8 @@ export class ToolkitCoordinator {
137
152
  * tools reports its failure again as fatal.
138
153
  */
139
154
  degradedTools = new Map();
140
- /** Tools a toolbar reported a module failure for, each reported once. */
141
- toolModuleFailures = new Set();
155
+ /** Tool failures already reported, by phase and tool, each reported once. */
156
+ reportedToolFailures = new Set();
142
157
  readyChangeListeners = new Set();
143
158
  eagerInit;
144
159
  toolRegistry;
@@ -157,7 +172,7 @@ export class ToolkitCoordinator {
157
172
  toolRegistryAbsent = false;
158
173
  toolContextResolvers = new Map();
159
174
  toolContextResolverChangeListeners = new Set();
160
- toolRequests = new ToolRequestRegistry();
175
+ toolRequests = new ToolRequestRegistry((toolId, phase, error) => this.reportToolFailure(toolId, phase, error));
161
176
  sectionControllers = new Map();
162
177
  sectionControllerKeys = new Map();
163
178
  sectionControllerInitEntries = new Map();
@@ -165,8 +180,8 @@ export class ToolkitCoordinator {
165
180
  sectionPersistenceStrategies = new Map();
166
181
  sectionControllerLifecycleListeners = new Set();
167
182
  /**
168
- * Phase D: every active section-event subscription, indexed by
169
- * listener identity. The listener follows the toolkit's active
183
+ * Every active section-event subscription, indexed by listener
184
+ * identity. The listener follows the toolkit's active
170
185
  * section cohort across transitions; this map is the registry the
171
186
  * coordinator iterates on every cohort change to detach the listener
172
187
  * from the outgoing controller, attach it to the new one, and replay
@@ -178,8 +193,8 @@ export class ToolkitCoordinator {
178
193
  */
179
194
  activeSubscriptions = new Map();
180
195
  /**
181
- * Phase D: map key of the section controller currently treated as
182
- * the *active cohort*. Set by `getOrCreateSectionController` (both
196
+ * Map key of the section controller currently treated as the
197
+ * *active cohort*. Set by `getOrCreateSectionController` (both
183
198
  * the create-new and resolve-existing paths) and cleared when the
184
199
  * matching controller is disposed, and `null` while the next requested
185
200
  * cohort is still starting.
@@ -196,6 +211,8 @@ export class ToolkitCoordinator {
196
211
  telemetryListeners = new Set();
197
212
  frameworkErrorBus;
198
213
  ownsFrameworkErrorBus;
214
+ /** The instance behind {@link toolCoordinator}, kept for disposal. */
215
+ ownedToolCoordinator;
199
216
  frameworkErrorHookUnsubscribe = null;
200
217
  disposePromise = null;
201
218
  /**
@@ -206,20 +223,12 @@ export class ToolkitCoordinator {
206
223
  */
207
224
  policyEngine;
208
225
  /**
209
- * Host-set override for PNP/profile enforcement, passed to the engine.
210
- * `null` (the default) is auto-mode, which the engine resolves per decision
211
- * from the policy material the bound assessment and the decision's item carry.
212
- * `"on"` / `"off"` are explicit host opt-in / opt-out and stick
213
- * across subsequent assessment swaps until the host clears
214
- * the override by calling `setPnpEnforcement(null)`.
215
- */
216
- pnpEnforcementOverride = null;
217
- /**
218
- * Policy diagnostics already logged, keyed by code, tool id and, for
219
- * `tool-policy.itemSettingNotApplied`, item id; see
226
+ * Policy diagnostics reported since the last input change, keyed by code,
227
+ * tool id and, for `tool-policy.itemSettingNotApplied`, item id; see
220
228
  * {@link warnPolicyDiagnostics}.
221
229
  */
222
- warnedPolicyDiagnostics = new Set();
230
+ reportedPolicyDiagnostics = new Map();
231
+ policyDiagnosticListeners = new Set();
223
232
  /**
224
233
  * Whether {@link decideFeaturePolicy} has already reported serving a decision
225
234
  * with no assessment bound.
@@ -324,7 +333,6 @@ export class ToolkitCoordinator {
324
333
  this.hooks = { ...resolvedConfig.hooks };
325
334
  this.lazyInit = config.lazyInit === true;
326
335
  this.eagerInit = config.eagerInit ?? !this.lazyInit;
327
- this.pnpEnforcementOverride = this.resolveConfiguredPnpEnforcement();
328
336
  // Use the host-provided framework-error bus if one was passed
329
337
  // (typical when embedded inside <pie-assessment-toolkit>, so
330
338
  // pre-coordinator failures from the CE flow through the same
@@ -333,15 +341,13 @@ export class ToolkitCoordinator {
333
341
  config.frameworkErrorBus ?? new FrameworkErrorBus();
334
342
  this.ownsFrameworkErrorBus = !config.frameworkErrorBus;
335
343
  this.subscribeFrameworkErrorHookAdapters();
336
- // Initialize all services
337
- this.toolCoordinator = new ToolCoordinator();
344
+ this.ownedToolCoordinator = new ToolCoordinator();
345
+ this.toolCoordinator = this.ownedToolCoordinator;
338
346
  this.highlightCoordinator = new HighlightCoordinator();
339
347
  this.elementToolStateStore = new ElementToolStateStore();
340
348
  this.catalogResolver = new AccessibilityCatalogResolver(resolvedConfig.accessibility?.catalogs || [], resolvedConfig.accessibility?.language || "en-US");
341
- // Initialize tool provider registry
342
349
  this.toolProviderRegistry = new ToolProviderRegistry();
343
350
  this._registerToolProviders();
344
- // Initialize TTS service based on config
345
351
  this.ttsService = new TTSService();
346
352
  // Selection read-aloud speaks through this service without the inline TTS
347
353
  // tool ever having run, so it cannot rely on that tool to attach highlights.
@@ -359,15 +365,23 @@ export class ToolkitCoordinator {
359
365
  this.policyEngine = new ToolPolicyEngine({
360
366
  toolRegistry: this.toolRegistry,
361
367
  contextId: `toolkit-coordinator:${this.assessmentId}`,
368
+ assessmentOptional: this.config.assessmentOptional === true,
362
369
  inputs: {
363
370
  tools: this.config.tools,
364
371
  assessment: null,
365
- pnpEnforcement: this.pnpEnforcementOverride,
372
+ pnpEnforcement: this.resolveConfiguredPnpEnforcement(),
366
373
  },
367
374
  });
375
+ // Input diagnostics are computed once per change, so they are reported
376
+ // here rather than with each decision.
368
377
  this.policyEngine.onPolicyChange((event) => {
369
- if (event.reason !== "disposed")
370
- this.reportNewlyGrantedFailures();
378
+ if (event.reason === "disposed")
379
+ return;
380
+ if (event.reason === "inputs" || event.reason === "pnp-enforcement") {
381
+ this.reportedPolicyDiagnostics.clear();
382
+ }
383
+ this.warnPolicyDiagnostics(event.inputs.diagnostics);
384
+ this.reportNewlyGrantedFailures();
371
385
  });
372
386
  if (this.eagerInit) {
373
387
  void this.waitUntilReady().catch((err) => {
@@ -460,25 +474,30 @@ export class ToolkitCoordinator {
460
474
  }
461
475
  /**
462
476
  * Every report site states whether the coordinator carries on after the
463
- * failure; one that takes down only its section passes `scope: "cohort"`.
477
+ * failure; one that takes down only its section passes that section.
464
478
  */
465
- handleError(error, context, scope = "runtime") {
479
+ handleError(error, context, cohort) {
466
480
  const model = frameworkErrorFromCoordinatorContext({
467
481
  error,
468
482
  context,
469
483
  recoverable: context.recoverable,
470
- scope,
484
+ scope: cohort ? "cohort" : "runtime",
485
+ cohort,
471
486
  });
472
487
  this.frameworkErrorBus.reportFrameworkError(model);
473
488
  }
474
489
  /**
475
490
  * Whether policy grants `toolId` as an accommodation on some surface: a
476
491
  * test-administration override, a mounted item's or a district requirement,
477
- * or profile support. Read without the unbound-assessment warning.
492
+ * or profile support. A toolbar tool counts only surfaces where PNP
493
+ * enforcement is on; a region feature ignores enforcement, as its decisions
494
+ * do. Read without the unbound-assessment warning.
478
495
  */
479
496
  isToolGranted(toolId) {
480
497
  try {
481
- return this.policyEngine.grantsFeatureAnywhere(toolId);
498
+ return this.policyEngine.grantsFeatureAnywhere(toolId, {
499
+ enforced: this.toolRegistry.getToolActivation(toolId) !== "region",
500
+ });
482
501
  }
483
502
  catch {
484
503
  return false;
@@ -490,7 +509,7 @@ export class ToolkitCoordinator {
490
509
  * `fallbackFollows` marks a failure another path absorbs, which is recoverable
491
510
  * whatever the policy.
492
511
  */
493
- reportToolFailure(error, context, toolIds, fallbackFollows = false) {
512
+ reportStartFailure(error, context, toolIds, fallbackFollows = false) {
494
513
  const ids = Array.from(toolIds);
495
514
  const recoverable = fallbackFollows || !ids.some((toolId) => this.isToolGranted(toolId));
496
515
  if (recoverable && !fallbackFollows) {
@@ -500,21 +519,32 @@ export class ToolkitCoordinator {
500
519
  this.handleError(error, { ...context, recoverable });
501
520
  }
502
521
  /**
503
- * Report that a toolbar could not load a tool's module. The failure follows
504
- * the start-failure policy: the tool degrades, so the toolbar withholds it,
505
- * unless policy grants it. Reported once per tool however many toolbars fail
506
- * to load it. Every report re-announces the request targets, since the
507
- * reporting toolbar no longer hosts the tool.
522
+ * Report that a tool failed in `phase`. A module that failed to load follows
523
+ * the start-failure policy: the tool degrades, so toolbars withhold it, unless
524
+ * policy grants it. Every other phase is recoverable, since its report site
525
+ * keeps a recovery, and is reported on a microtask. Reported once per tool and
526
+ * phase however many toolbars or instances fail. A module failure re-announces the request targets, since
527
+ * the reporting toolbar no longer hosts the tool.
508
528
  */
509
- reportToolModuleFailure(toolId, error) {
510
- this.toolRequests.notifyTargetsChange();
511
- if (this.toolModuleFailures.has(toolId))
529
+ reportToolFailure(toolId, phase, error) {
530
+ if (phase === "tool-module-load")
531
+ this.toolRequests.notifyTargetsChange();
532
+ const key = `${phase}\u0000${toolId}`;
533
+ if (this.reportedToolFailures.has(key))
512
534
  return;
513
- this.toolModuleFailures.add(toolId);
535
+ this.reportedToolFailures.add(key);
514
536
  const detail = error instanceof Error && error.message.trim().length > 0
515
537
  ? error.message
516
538
  : String(error);
517
- this.reportToolFailure(new Error(`Tool "${toolId}" failed to load: ${detail}`, { cause: error }), { phase: "tool-module-load", details: { toolId } }, [toolId]);
539
+ const report = () => this.reportStartFailure(new Error(`Tool "${toolId}" ${TOOL_FAILURE_SUMMARY[phase]}: ${detail}`, {
540
+ cause: error,
541
+ }), { phase, details: { toolId } }, [toolId], phase !== "tool-module-load");
542
+ // The recoverable phases fail inside a toolbar's derived state, where a
543
+ // listener writing state would throw.
544
+ if (phase === "tool-module-load")
545
+ report();
546
+ else
547
+ queueMicrotask(report);
518
548
  }
519
549
  reportNewlyGrantedFailures() {
520
550
  for (const [toolId, failure] of this.degradedTools) {
@@ -573,6 +603,13 @@ export class ToolkitCoordinator {
573
603
  reportFrameworkError(model) {
574
604
  this.frameworkErrorBus.reportFrameworkError(model);
575
605
  }
606
+ sectionControllerKey(args) {
607
+ return {
608
+ assessmentId: this.assessmentId,
609
+ sectionId: args.sectionId,
610
+ attemptId: args.attemptId,
611
+ };
612
+ }
576
613
  getSectionControllerMapKey(key) {
577
614
  return `${key.assessmentId}::${key.sectionId}::${key.attemptId || ""}`;
578
615
  }
@@ -662,7 +699,6 @@ export class ToolkitCoordinator {
662
699
  if (state && typeof state === "object") {
663
700
  this.elementToolStateStore.loadState(state);
664
701
  }
665
- this.stateLoaded = true;
666
702
  this.stateLoadSettled = true;
667
703
  await this.emitTelemetry("pie-toolkit-tool-state-loaded", {
668
704
  hasState: Boolean(state),
@@ -754,9 +790,6 @@ export class ToolkitCoordinator {
754
790
  this.scheduleTTSReconfigure();
755
791
  return true;
756
792
  }
757
- /**
758
- * Register tool providers in the registry
759
- */
760
793
  _registerToolProviders() {
761
794
  const descriptorTools = this.getProviderDescriptorTools();
762
795
  for (const tool of descriptorTools) {
@@ -853,7 +886,7 @@ export class ToolkitCoordinator {
853
886
  */
854
887
  reportProviderRegisterFailure(err, toolId) {
855
888
  console.warn(`[ToolkitCoordinator] Failed to register the provider of tool "${toolId}":`, err);
856
- this.reportToolFailure(err, { phase: "provider-register", toolId }, [toolId]);
889
+ this.reportStartFailure(err, { phase: "provider-register", toolId }, [toolId]);
857
890
  }
858
891
  async ensureProviderReady(toolId) {
859
892
  return this.initializeProvider(toolId, false);
@@ -890,7 +923,7 @@ export class ToolkitCoordinator {
890
923
  if (err instanceof ToolkitCoordinatorDisposedError)
891
924
  throw err;
892
925
  const error = err instanceof Error ? err : new Error(String(err));
893
- this.reportToolFailure(error, { phase: "provider-init", toolId },
926
+ this.reportStartFailure(error, { phase: "provider-init", toolId },
894
927
  // Attributed when the id names a tool with a provider; any other id
895
928
  // was never a tool's.
896
929
  this.toolRegistry.get(toolId)?.provider ? [toolId] : [], fallbackFollows);
@@ -920,21 +953,15 @@ export class ToolkitCoordinator {
920
953
  };
921
954
  }
922
955
  getSectionController(args) {
923
- const key = {
924
- assessmentId: this.assessmentId,
925
- sectionId: args.sectionId,
926
- attemptId: args.attemptId,
927
- };
956
+ const key = this.sectionControllerKey(args);
928
957
  return this.sectionControllers.get(this.getSectionControllerMapKey(key));
929
958
  }
930
959
  subscribeSectionEvents(args) {
931
- // Phase D: subscriptions follow the toolkit's *active section
932
- // cohort* automatically. We bind the listener to whichever
933
- // controller is currently active and re-bind it on every
934
- // `getOrCreateSectionController` / `disposeSectionController`
935
- // transition, with snapshot replay on each migration. A listener
936
- // added while the next section is still starting binds when it
937
- // becomes active.
960
+ // The listener binds to whichever controller is currently active and
961
+ // re-binds on every `getOrCreateSectionController` /
962
+ // `disposeSectionController` transition, with snapshot replay on each
963
+ // migration. A listener added while the next section is still
964
+ // starting binds when it becomes active.
938
965
  if (this.activeCohortMapKey === null &&
939
966
  this.latestRequestedActiveCohortMapKey === null) {
940
967
  throw new Error("[ToolkitCoordinator] subscribeSectionEvents requires an active section cohort; call getOrCreateSectionController first.");
@@ -973,7 +1000,7 @@ export class ToolkitCoordinator {
973
1000
  };
974
1001
  }
975
1002
  /**
976
- * Bind a Phase D subscription to a section controller. Detaches any
1003
+ * Bind an active-cohort subscription to a section controller. Detaches any
977
1004
  * prior controller binding the subscription was holding, attaches a
978
1005
  * fresh `controller.subscribe(...)` callback, and replays the
979
1006
  * canonical late-subscribe sequence (content-loaded × N then
@@ -1032,15 +1059,9 @@ export class ToolkitCoordinator {
1032
1059
  const controller = this.sectionControllers.get(mapKey);
1033
1060
  if (!controller)
1034
1061
  return;
1035
- // Snapshot the subscription list before iterating: a listener may
1036
- // synchronously call `subscribeSectionEvents(...)` from inside its
1037
- // replay delivery, which inserts a new entry into
1038
- // `activeSubscriptions`. Map iteration yields keys inserted
1039
- // during the loop; without snapshotting, the inner subscribe path
1040
- // would bind the new listener once (correctly, with replay) and
1041
- // the outer loop would then visit it a second time, double-
1042
- // replaying the snapshot. Snapshot keeps "replay once per cohort
1043
- // transition" intact.
1062
+ // Iterate a snapshot: a listener may call `subscribeSectionEvents` from
1063
+ // inside its replay delivery, and live Map iteration would then visit the
1064
+ // new entry and replay it a second time.
1044
1065
  for (const sub of Array.from(this.activeSubscriptions.values())) {
1045
1066
  this.bindSubscriptionToController(sub, controller);
1046
1067
  }
@@ -1056,10 +1077,7 @@ export class ToolkitCoordinator {
1056
1077
  if (this.activeCohortMapKey === mapKey)
1057
1078
  return;
1058
1079
  this.activeCohortMapKey = null;
1059
- for (const sub of Array.from(this.activeSubscriptions.values())) {
1060
- sub.unsubscribeCurrent?.();
1061
- sub.unsubscribeCurrent = null;
1062
- }
1080
+ this.detachActiveSubscriptions();
1063
1081
  }
1064
1082
  /**
1065
1083
  * Clear the active cohort if `mapKey` matches it, detaching every
@@ -1071,10 +1089,14 @@ export class ToolkitCoordinator {
1071
1089
  if (this.activeCohortMapKey !== mapKey)
1072
1090
  return;
1073
1091
  this.activeCohortMapKey = null;
1074
- // Symmetric snapshot for safety: a disposer fired during detach
1075
- // could mutate `activeSubscriptions`. Detach is silent (no listener
1076
- // fan-out), so re-entrancy exposure is lower than `setActiveCohort`,
1077
- // but the snapshot keeps both paths uniform.
1092
+ this.detachActiveSubscriptions();
1093
+ }
1094
+ /**
1095
+ * Detach every active subscription from its controller. Iterates a snapshot,
1096
+ * as `setActiveCohort` does, because a disposer may mutate
1097
+ * `activeSubscriptions`.
1098
+ */
1099
+ detachActiveSubscriptions() {
1078
1100
  for (const sub of Array.from(this.activeSubscriptions.values())) {
1079
1101
  sub.unsubscribeCurrent?.();
1080
1102
  sub.unsubscribeCurrent = null;
@@ -1084,20 +1106,14 @@ export class ToolkitCoordinator {
1084
1106
  const eventTypeFilter = args.eventTypes ? new Set(args.eventTypes) : null;
1085
1107
  const itemIdFilter = args.itemIds ? new Set(args.itemIds) : null;
1086
1108
  return (event) => {
1087
- if (eventTypeFilter || itemIdFilter) {
1088
- const eventType = event?.type || null;
1089
- if (eventTypeFilter &&
1090
- (!eventType || !eventTypeFilter.has(eventType))) {
1109
+ if (eventTypeFilter) {
1110
+ const eventType = event?.type;
1111
+ if (!eventType || !eventTypeFilter.has(eventType))
1091
1112
  return false;
1092
- }
1093
- if (itemIdFilter) {
1094
- const hasMatchingItem = Array.from(this.collectEventItemIds(event)).some((itemId) => itemIdFilter.has(itemId));
1095
- if (!hasMatchingItem) {
1096
- return false;
1097
- }
1098
- }
1099
1113
  }
1100
- return true;
1114
+ if (!itemIdFilter)
1115
+ return true;
1116
+ return Array.from(this.collectEventItemIds(event)).some((itemId) => itemIdFilter.has(itemId));
1101
1117
  };
1102
1118
  }
1103
1119
  collectEventItemIds(event) {
@@ -1223,11 +1239,7 @@ export class ToolkitCoordinator {
1223
1239
  if (this.disposePromise !== null) {
1224
1240
  throw new ToolkitCoordinatorDisposedError();
1225
1241
  }
1226
- const key = {
1227
- assessmentId: this.assessmentId,
1228
- sectionId: args.sectionId,
1229
- attemptId: args.attemptId,
1230
- };
1242
+ const key = this.sectionControllerKey(args);
1231
1243
  const mapKey = this.getSectionControllerMapKey(key);
1232
1244
  this.latestRequestedActiveCohortMapKey = mapKey;
1233
1245
  this.suspendActiveCohortIfSuperseded(mapKey);
@@ -1316,8 +1328,7 @@ export class ToolkitCoordinator {
1316
1328
  if (this.sectionControllers.get(args.mapKey) !== existingController) {
1317
1329
  return undefined;
1318
1330
  }
1319
- // PIE-512 Phase D: a `getOrCreateSectionController` call that
1320
- // resolves to a previously-created controller still represents a
1331
+ // A `getOrCreateSectionController` call that resolves to a previously-created controller still represents a
1321
1332
  // cohort transition from the toolkit's perspective (same-cohort
1322
1333
  // re-entry is a no-op inside `setActiveCohort`). Active
1323
1334
  // subscriptions migrate here so a host that subscribed once on
@@ -1457,8 +1468,7 @@ export class ToolkitCoordinator {
1457
1468
  this.sectionControllers.set(args.mapKey, args.controller);
1458
1469
  this.sectionControllerKeys.set(args.mapKey, args.key);
1459
1470
  args.token.candidateClaimed = true;
1460
- // PIE-512 Phase D: a freshly-resolved controller becomes the
1461
- // active cohort. Active subscriptions migrate to it before the
1471
+ // A freshly-resolved controller becomes the active cohort. Active subscriptions migrate to it before the
1462
1472
  // `ready` lifecycle event and `onSectionControllerReady` hook
1463
1473
  // fire so any synchronous post-ready work observes a coherent
1464
1474
  // active-cohort view.
@@ -1489,7 +1499,7 @@ export class ToolkitCoordinator {
1489
1499
  attemptId: args.attemptId,
1490
1500
  },
1491
1501
  recoverable: false,
1492
- }, "cohort");
1502
+ }, { sectionId: args.sectionId, attemptId: args.attemptId });
1493
1503
  }
1494
1504
  /**
1495
1505
  * A section that fails to start has no controller of its own to emit its
@@ -1517,11 +1527,7 @@ export class ToolkitCoordinator {
1517
1527
  }
1518
1528
  }
1519
1529
  disposeSectionController(args) {
1520
- const key = {
1521
- assessmentId: this.assessmentId,
1522
- sectionId: args.sectionId,
1523
- attemptId: args.attemptId,
1524
- };
1530
+ const key = this.sectionControllerKey(args);
1525
1531
  const mapKey = this.getSectionControllerMapKey(key);
1526
1532
  const initEntry = this.sectionControllerInitEntries.get(mapKey);
1527
1533
  if (initEntry) {
@@ -1538,8 +1544,7 @@ export class ToolkitCoordinator {
1538
1544
  return this.trackSectionControllerDisposal(mapKey, retirementBarrier);
1539
1545
  }
1540
1546
  const persistenceStrategy = this.sectionPersistenceStrategies.get(mapKey);
1541
- // PIE-512 Phase D: if the disposing cohort is the active one,
1542
- // detach all listener-controller bindings before the controller
1547
+ // If the disposing cohort is the active one, detach all listener-controller bindings before the controller
1543
1548
  // itself disposes. The subscription registry stays intact so a
1544
1549
  // later `getOrCreateSectionController(...)` re-binds the same
1545
1550
  // listeners to the new controller.
@@ -1704,14 +1709,13 @@ export class ToolkitCoordinator {
1704
1709
  await cleanup(() => this.ttsService.dispose());
1705
1710
  await cleanup(() => this.toolProviderRegistry.destroy());
1706
1711
  await cleanup(() => this.highlightCoordinator.destroy());
1707
- for (const toolId of this.toolCoordinator.getRegisteredTools()) {
1708
- await cleanup(() => this.toolCoordinator.releaseTool(toolId));
1709
- }
1712
+ await cleanup(() => this.ownedToolCoordinator.destroy());
1710
1713
  await cleanup(() => this.catalogResolver.destroy());
1711
1714
  await cleanup(() => this.toolRequests.dispose());
1712
1715
  await cleanup(() => this.policyEngine.dispose());
1713
1716
  this.toolContextResolvers.clear();
1714
1717
  this.toolContextResolverChangeListeners.clear();
1718
+ this.policyDiagnosticListeners.clear();
1715
1719
  this.sectionControllerLifecycleListeners.clear();
1716
1720
  this.telemetryListeners.clear();
1717
1721
  this.frameworkErrorHookUnsubscribe?.();
@@ -1741,9 +1745,9 @@ export class ToolkitCoordinator {
1741
1745
  await Promise.allSettled(pending);
1742
1746
  }
1743
1747
  /**
1744
- * Initialize TTS service with provider
1748
+ * Initialize the TTS service from the `textToSpeech` tool config.
1745
1749
  */
1746
- async ensureTTSReady(config) {
1750
+ async ensureTTSReady() {
1747
1751
  this.assertNotDisposed();
1748
1752
  await this.waitForPendingTTSReconfigure();
1749
1753
  this.assertNotDisposed();
@@ -1751,7 +1755,7 @@ export class ToolkitCoordinator {
1751
1755
  return;
1752
1756
  if (this.ttsInitPromise)
1753
1757
  return this.ttsInitPromise;
1754
- this.ttsInitPromise = this._initializeTTS(config)
1758
+ this.ttsInitPromise = this._initializeTTS()
1755
1759
  .then(() => this.assertNotDisposed())
1756
1760
  .finally(() => {
1757
1761
  this.ttsInitPromise = undefined;
@@ -1766,10 +1770,10 @@ export class ToolkitCoordinator {
1766
1770
  pending = this.ttsReconfigurePromise;
1767
1771
  }
1768
1772
  }
1769
- async _initializeTTS(config) {
1773
+ async _initializeTTS() {
1770
1774
  if (this.ttsInitialized)
1771
1775
  return;
1772
- const resolvedToolConfig = this.resolveTTSToolConfig(config);
1776
+ const resolvedToolConfig = this.resolveTTSToolConfig();
1773
1777
  const runtimeSettings = resolveTTSRuntimeSettings(resolvedToolConfig);
1774
1778
  const resolvedBackend = resolveTTSBackend(runtimeSettings);
1775
1779
  const runtimeTTSConfig = buildRuntimeTTSConfig(runtimeSettings);
@@ -1788,7 +1792,6 @@ export class ToolkitCoordinator {
1788
1792
  operation: "tts-init",
1789
1793
  backend: resolvedBackend,
1790
1794
  });
1791
- // Try to use TTS provider from registry if available
1792
1795
  if (this.toolProviderRegistry.has("textToSpeech")) {
1793
1796
  try {
1794
1797
  // Browser speech follows a server provider that fails to start.
@@ -1845,7 +1848,7 @@ export class ToolkitCoordinator {
1845
1848
  if (error instanceof ToolkitCoordinatorDisposedError)
1846
1849
  throw error;
1847
1850
  const normalized = error instanceof Error ? error : new Error(String(error));
1848
- this.reportToolFailure(normalized, { phase: "tts-init" }, ["textToSpeech"]);
1851
+ this.reportStartFailure(normalized, { phase: "tts-init" }, ["textToSpeech"]);
1849
1852
  await this.emitTelemetry("pie-toolkit-tts-init-error", {
1850
1853
  message: normalized.message,
1851
1854
  });
@@ -1859,8 +1862,8 @@ export class ToolkitCoordinator {
1859
1862
  throw normalized;
1860
1863
  }
1861
1864
  }
1862
- resolveTTSToolConfig(config) {
1863
- return config || this.getTTSConfigFromProviders() || {};
1865
+ resolveTTSToolConfig() {
1866
+ return this.getTTSConfigFromProviders() || {};
1864
1867
  }
1865
1868
  async initializeTTSService(provider, config) {
1866
1869
  const nextProviderOptions = {
@@ -1933,7 +1936,7 @@ export class ToolkitCoordinator {
1933
1936
  */
1934
1937
  async startTTSForReadiness() {
1935
1938
  try {
1936
- await this.ensureTTSReady(this.getTTSConfigFromProviders());
1939
+ await this.ensureTTSReady();
1937
1940
  }
1938
1941
  catch (error) {
1939
1942
  if (error instanceof ToolkitCoordinatorDisposedError)
@@ -1951,13 +1954,11 @@ export class ToolkitCoordinator {
1951
1954
  if (typeof toolId !== "string" || toolId.trim().length === 0) {
1952
1955
  throw new Error("Tool id must be a non-empty string.");
1953
1956
  }
1954
- // An empty registry means the host supplied none, not that every id is
1955
- // wrong. There is nothing to check an id against, and throwing turns a
1956
- // host's every tool-config call into an exception — including the calls
1957
- // this coordinator's own default-provider block provokes, which is how a
1958
- // host that passes no registry ended up unable to read its own config.
1959
- // Validation reports the missing registry once per coordinator, as
1960
- // `tools.registryUnavailable`; a report per call would be noise.
1957
+ // An empty registry means the host supplied none, so there is nothing to
1958
+ // check an id against. Throwing here would fail every tool-config call,
1959
+ // the default providers' own included, and once left a host without a
1960
+ // registry unable to read its config. Validation reports the missing
1961
+ // registry once per coordinator, as `tools.registryUnavailable`.
1961
1962
  if (this.toolRegistry.getAllToolIds().length === 0)
1962
1963
  return;
1963
1964
  if (!this.toolRegistry.get(toolId)) {
@@ -2002,7 +2003,6 @@ export class ToolkitCoordinator {
2002
2003
  return this.config.tools?.providers?.[toolId] || null;
2003
2004
  }
2004
2005
  updateToolConfig(toolId, updates) {
2005
- // Update config
2006
2006
  this.assertCanonicalToolId(toolId);
2007
2007
  const current = this.getToolConfig(toolId) || {};
2008
2008
  if (!this.config.tools) {
@@ -2016,10 +2016,10 @@ export class ToolkitCoordinator {
2016
2016
  this.config.tools.providers = {};
2017
2017
  }
2018
2018
  const nextProviders = {
2019
- ...(this.config.tools.providers || {}),
2019
+ ...this.config.tools.providers,
2020
2020
  [toolId]: mergeToolConfigUpdate(toolId, current, updates),
2021
2021
  };
2022
- this.config.tools = this.validateToolsConfig({
2022
+ const validated = this.validateToolsConfig({
2023
2023
  ...this.config.tools,
2024
2024
  providers: nextProviders,
2025
2025
  }, {
@@ -2027,13 +2027,11 @@ export class ToolkitCoordinator {
2027
2027
  source: "ToolkitCoordinator.updateToolConfig",
2028
2028
  toolRegistry: this.toolRegistry,
2029
2029
  });
2030
+ this.config.tools = validated;
2030
2031
  // The engine's tools input follows the validated config; its `inputs`
2031
2032
  // change event is what makes toolbars re-decide.
2032
- this.policyEngine.updateInputs({
2033
- tools: this.config.tools,
2034
- });
2033
+ this.policyEngine.updateInputs({ tools: validated });
2035
2034
  void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
2036
- // Apply configuration changes to services
2037
2035
  this._applyToolConfigChange(toolId);
2038
2036
  }
2039
2037
  /**
@@ -2072,9 +2070,7 @@ export class ToolkitCoordinator {
2072
2070
  });
2073
2071
  validated.placement = nextPlacement;
2074
2072
  this.config.tools = validated;
2075
- this.policyEngine.updateInputs({
2076
- tools: this.config.tools,
2077
- });
2073
+ this.policyEngine.updateInputs({ tools: validated });
2078
2074
  }
2079
2075
  /**
2080
2076
  * Resolve the visible tool set for a given placement level + scope.
@@ -2088,9 +2084,11 @@ export class ToolkitCoordinator {
2088
2084
  return decision;
2089
2085
  }
2090
2086
  /**
2091
- * Log each policy diagnostic as a console warning, once per code and tool
2092
- * id, and per item for `tool-policy.itemSettingNotApplied`. Decisions re-run
2093
- * on every input change, so a conflict would otherwise repeat on each.
2087
+ * Report each policy diagnostic as a console warning and to the
2088
+ * {@link onPolicyDiagnostic} listeners, once per code and tool id, and per
2089
+ * item for `tool-policy.itemSettingNotApplied`. Every toolbar and feature
2090
+ * asks for decisions, so a conflict would otherwise repeat on each; the
2091
+ * engine's input diagnostics arrive with each policy change.
2094
2092
  */
2095
2093
  warnPolicyDiagnostics(diagnostics) {
2096
2094
  for (const diagnostic of diagnostics) {
@@ -2098,10 +2096,21 @@ export class ToolkitCoordinator {
2098
2096
  ? diagnostic.details.itemId
2099
2097
  : "";
2100
2098
  const key = `${diagnostic.code}\0${diagnostic.toolId}\0${itemId}`;
2101
- if (this.warnedPolicyDiagnostics.has(key))
2099
+ if (this.reportedPolicyDiagnostics.has(key))
2102
2100
  continue;
2103
- this.warnedPolicyDiagnostics.add(key);
2101
+ this.reportedPolicyDiagnostics.set(key, diagnostic);
2104
2102
  console.warn(`[ToolkitCoordinator] ${diagnostic.message}`);
2103
+ for (const listener of Array.from(this.policyDiagnosticListeners)) {
2104
+ this.notifyPolicyDiagnosticListener(listener, diagnostic);
2105
+ }
2106
+ }
2107
+ }
2108
+ notifyPolicyDiagnosticListener(listener, diagnostic) {
2109
+ try {
2110
+ listener(diagnostic);
2111
+ }
2112
+ catch (error) {
2113
+ console.error("[ToolkitCoordinator] Policy diagnostic listener failed:", error);
2105
2114
  }
2106
2115
  }
2107
2116
  /**
@@ -2123,8 +2132,8 @@ export class ToolkitCoordinator {
2123
2132
  * `pnpEnforcement` is not consulted.
2124
2133
  *
2125
2134
  * Reports once per coordinator when it is asked about a feature with no
2126
- * assessment bound. A host in that state gets a correct denial for every
2127
- * capability it asks about, which is indistinguishable from a student who was
2135
+ * assessment bound. A host in that state gets a denial for every capability
2136
+ * no item requires, which is indistinguishable from a student who was
2128
2137
  * properly declined — so without this, forgetting {@link updateAssessment}
2129
2138
  * presents as an accommodation that silently never appears.
2130
2139
  *
@@ -2139,13 +2148,11 @@ export class ToolkitCoordinator {
2139
2148
  decideFeaturePolicy(featureId, scope) {
2140
2149
  const decision = this.policyEngine.decideFeature(featureId, scope);
2141
2150
  this.warnPolicyDiagnostics(decision.diagnostics);
2142
- const unboundIsMisconfigured = this.config.assessmentOptional !== true ||
2143
- this.pnpEnforcementOverride === "on";
2144
2151
  if (!decision.assessmentBound &&
2145
- unboundIsMisconfigured &&
2152
+ this.policyEngine.getInputs().assessmentExpected &&
2146
2153
  !this.reportedUnboundFeaturePolicy) {
2147
2154
  this.reportedUnboundFeaturePolicy = true;
2148
- console.warn(`[ToolkitCoordinator] Feature policy was asked about "${featureId}" with no assessment bound, so every capability will be declined for want of a profile to read. Call updateAssessment(...) with the assessment (its personalNeedsProfile, settings.districtPolicy and settings.testAdministration are what policy reads) before relying on any accommodation. Reported once per coordinator.`);
2155
+ console.warn(`[ToolkitCoordinator] Feature policy was asked about "${featureId}" with no assessment bound, so profile, district and test-administration policy have nothing to read and only an item's requiredTools can grant. Pass the assessment to the toolkit's assessment property, or call updateAssessment(...), before relying on any accommodation. Reported once per coordinator.`);
2149
2156
  }
2150
2157
  return decision;
2151
2158
  }
@@ -2169,6 +2176,15 @@ export class ToolkitCoordinator {
2169
2176
  onPolicyChange(listener) {
2170
2177
  return this.policyEngine.onPolicyChange(listener);
2171
2178
  }
2179
+ onPolicyDiagnostic(listener) {
2180
+ this.policyDiagnosticListeners.add(listener);
2181
+ for (const diagnostic of Array.from(this.reportedPolicyDiagnostics.values())) {
2182
+ this.notifyPolicyDiagnosticListener(listener, diagnostic);
2183
+ }
2184
+ return () => {
2185
+ this.policyDiagnosticListeners.delete(listener);
2186
+ };
2187
+ }
2172
2188
  /**
2173
2189
  * Subscribe to accessibility-catalog registrations and removals.
2174
2190
  *
@@ -2200,10 +2216,10 @@ export class ToolkitCoordinator {
2200
2216
  }
2201
2217
  /**
2202
2218
  * Override the auto-mode PNP/profile enforcement decision; `null` returns
2203
- * to auto-mode.
2219
+ * to auto-mode. `"on"` and `"off"` stick across assessment swaps until
2220
+ * cleared.
2204
2221
  */
2205
2222
  setPnpEnforcement(mode) {
2206
- this.pnpEnforcementOverride = mode;
2207
2223
  this.policyEngine.updateInputs({ pnpEnforcement: mode });
2208
2224
  }
2209
2225
  /**
@@ -2314,13 +2330,9 @@ export class ToolkitCoordinator {
2314
2330
  return this.toolRequests.onTargetsChange(listener);
2315
2331
  }
2316
2332
  resolveConfiguredPnpEnforcement() {
2317
- const tools = this.config.tools;
2318
- return tools?.pnpEnforcement ?? null;
2333
+ return this.config.tools?.pnpEnforcement ?? null;
2319
2334
  }
2320
- /**
2321
- * Apply tool configuration changes to underlying services.
2322
- * Called after updateToolConfig().
2323
- */
2335
+ /** Apply an `updateToolConfig` change to the underlying services. */
2324
2336
  _applyToolConfigChange(toolId) {
2325
2337
  if (this.disposePromise !== null)
2326
2338
  return;