@sublang/playbook 5.0.0 → 6.0.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 (65) hide show
  1. package/README.md +11 -7
  2. package/docs/cli.md +38 -35
  3. package/docs/configuration.md +58 -15
  4. package/docs/embedding.md +24 -16
  5. package/package.json +40 -21
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
  8. package/reference/sdlc/code.md +55 -97
  9. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  10. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  11. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  12. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  13. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  14. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  15. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  16. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  17. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  18. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  19. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  20. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  21. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  22. package/reference/sdlc/code.playbook/playbook-captain.js +93 -15
  23. package/reference/sdlc/code.playbook/playbook-captain.ts +115 -19
  24. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  25. package/reference/sdlc/decide.md +54 -0
  26. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  27. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  29. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  30. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  31. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +471 -362
  32. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +575 -443
  33. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  34. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  35. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  36. package/reference/sdlc/review.md +81 -0
  37. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  38. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  39. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  40. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  41. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  42. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  43. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  44. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  45. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  46. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  47. package/slc/gears2fsm.md +13 -4
  48. package/slc/link.md +48 -2
  49. package/slc/text2gears.md +22 -2
  50. package/src/runtime.d.ts +7 -0
  51. package/src/runtime.ts +12 -0
  52. package/src/xstate-playbook-runtime.d.ts +9 -2
  53. package/src/xstate-playbook-runtime.js +255 -21
  54. package/src/xstate-playbook-runtime.ts +333 -28
  55. package/src/xstate-runtime.js +25 -0
  56. package/src/xstate-runtime.ts +51 -0
  57. package/reference/sdlc/discuss.md +0 -93
  58. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  59. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  60. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  61. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  62. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  63. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  64. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  65. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -1,24 +1,20 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  //
4
- // PlaybookRuntime for the discuss playbook, linked from the FSM artifact by
4
+ // PlaybookRuntime for the decide playbook, linked from the FSM artifact by
5
5
  // the slc FSM-to-runtime link phase.
6
6
  //
7
7
  // Linker inputs:
8
- // FSM artifact: ./discuss.fsm.ts
9
- // Link target: @sublang/playbook/src/runtime.ts
10
- // Player binding: Host -> host, Participant -> participant,
11
- // Committer -> committer
8
+ // FSM artifact: ./decide.fsm.ts
9
+ // Link target: @sublang/playbook/runtime
10
+ // Player binding: Coder -> coder, Reviewer -> reviewer
12
11
  // (default binding: lowercased player name)
13
- // Composite players: Committer = Host | Participant. DISCUSS-14 and
14
- // DISCUSS-15 keep PlayerInput.player = 'Committer';
15
- // callPlayer resolution uses options.committer when
16
- // supplied, otherwise falls back to Host, the first
17
- // listed alias alternative.
18
12
  // Adjudication: LLM-judge per state (default)
19
13
  // Boss-event mapping: free-text judge classification (default)
20
14
  // Abort strategy: natural rejection; every player-invoking state's
21
15
  // onError routes to the quiescent failed state.
16
+ // Output profile: bespoke parallel runtime with the shared nested-call
17
+ // bridge (slc/link.md §Output)
22
18
 
23
19
  import PQueue from 'p-queue';
24
20
  import { createActor, fromPromise } from 'xstate';
@@ -28,6 +24,7 @@ import {
28
24
  assertJsonSafe,
29
25
  assertPlaybookRuntimeSnapshot,
30
26
  combineAbortSignals,
27
+ createNestedPlaybookBridge,
31
28
  detachPersistedMachineSnapshot,
32
29
  normalizeError,
33
30
  normalizePlaybookSnapshot,
@@ -36,14 +33,16 @@ import {
36
33
  validatePlayerResult,
37
34
  waitForPlaybookQuiescence,
38
35
  } from '../../../src/xstate-runtime.js';
36
+ import type { NestedPlaybookBridge } from '../../../src/xstate-runtime.js';
39
37
 
40
- import discussMachine, {
38
+ import decideMachine, {
41
39
  type PlayerInput,
42
40
  type PlayerOutput,
43
- type DiscussEvent,
44
- type DiscussInput,
41
+ type DecideEvent,
42
+ type DecideInput,
45
43
  type PendingBossQuestion,
46
- } from './discuss.fsm.js';
44
+ type PlaybookInput,
45
+ } from './decide.fsm.js';
47
46
 
48
47
  import type {
49
48
  CaptainCallOptions,
@@ -52,9 +51,13 @@ import type {
52
51
  NormalizedError,
53
52
  PlayerCallOptions,
54
53
  PlayerResult,
54
+ PlayerSessionStore,
55
55
  PlaybookCallRequest,
56
56
  PlaybookCallResult,
57
57
  PlaybookCallStart,
58
+ PlaybookControlAction,
59
+ PlaybookControlReceipt,
60
+ PlaybookControlView,
58
61
  PlaybookPendingCall,
59
62
  PlaybookPorts,
60
63
  PlaybookRunResult,
@@ -75,9 +78,13 @@ export type {
75
78
  NormalizedError,
76
79
  PlayerCallOptions,
77
80
  PlayerResult,
81
+ PlayerSessionStore,
78
82
  PlaybookCallRequest,
79
83
  PlaybookCallResult,
80
84
  PlaybookCallStart,
85
+ PlaybookControlAction,
86
+ PlaybookControlReceipt,
87
+ PlaybookControlView,
81
88
  PlaybookPendingCall,
82
89
  PlaybookPorts,
83
90
  PlaybookRunResult,
@@ -91,68 +98,53 @@ export type {
91
98
  PlaybookTraceType,
92
99
  };
93
100
 
94
- type PlayerName = 'Host' | 'Participant' | 'Committer';
95
- type ReviewScope = 'specItems' | 'decisionRecords' | 'mixed';
101
+ type PlayerName = 'Coder' | 'Reviewer';
96
102
 
97
- export interface PlaybookRuntimeOptions extends DiscussInput {
103
+ export interface PlaybookRuntimeOptions extends DecideInput {
98
104
  playerBinding?: Partial<Record<PlayerName, string>>;
99
105
  }
100
106
 
101
107
  const DEFAULT_PLAYER_BINDING: Readonly<Record<PlayerName, string>> = {
102
- Host: 'host',
103
- Participant: 'participant',
104
- Committer: 'committer',
108
+ Coder: 'coder',
109
+ Reviewer: 'reviewer',
105
110
  };
106
111
 
107
- const ALIAS_RESOLUTION: Readonly<Record<string, string>> = {
108
- 'DISCUSS-14':
109
- 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
110
- 'DISCUSS-15':
111
- 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
112
- };
113
-
114
- function snapshotDiscussRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
115
- const captured = snapshotJsonValue(value, 'DISCUSS runtime options');
112
+ function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
113
+ const captured = snapshotJsonValue(value, 'DECIDE runtime options');
116
114
  if (!isPlainObject(captured)) {
117
- throw new TypeError('DISCUSS runtime options must be an object');
115
+ throw new TypeError('DECIDE runtime options must be an object');
118
116
  }
119
- const allowed = new Set([
120
- 'host',
121
- 'participant',
122
- 'committer',
123
- 'playerBinding',
124
- ]);
117
+ const allowed = new Set(['coderLlm', 'playerBinding']);
125
118
  for (const key of Object.keys(captured)) {
126
119
  if (!allowed.has(key)) {
127
- throw new TypeError(`DISCUSS runtime options.${key} is not declared`);
120
+ throw new TypeError(`DECIDE runtime options.${key} is not declared`);
128
121
  }
129
122
  }
130
- for (const key of ['host', 'participant', 'committer'] as const) {
131
- if (key in captured && typeof captured[key] !== 'string') {
132
- throw new TypeError(`DISCUSS runtime options.${key} must be a string`);
133
- }
123
+ if (
124
+ typeof captured.coderLlm !== 'string' ||
125
+ captured.coderLlm.trim().length === 0
126
+ ) {
127
+ throw new TypeError(
128
+ 'DECIDE runtime options.coderLlm must be a non-empty string',
129
+ );
134
130
  }
135
131
  if ('playerBinding' in captured) {
136
132
  const playerBinding = captured.playerBinding;
137
133
  if (!isPlainObject(playerBinding)) {
138
134
  throw new TypeError(
139
- 'DISCUSS runtime options.playerBinding must be an object',
135
+ 'DECIDE runtime options.playerBinding must be an object',
140
136
  );
141
137
  }
142
- const playerNames = new Set<PlayerName>([
143
- 'Host',
144
- 'Participant',
145
- 'Committer',
146
- ]);
138
+ const playerNames = new Set<PlayerName>(['Coder', 'Reviewer']);
147
139
  for (const [player, playerId] of Object.entries(playerBinding)) {
148
140
  if (!playerNames.has(player as PlayerName)) {
149
141
  throw new TypeError(
150
- `DISCUSS runtime options.playerBinding.${player} is not declared`,
142
+ `DECIDE runtime options.playerBinding.${player} is not declared`,
151
143
  );
152
144
  }
153
145
  if (typeof playerId !== 'string' || playerId.trim().length === 0) {
154
146
  throw new TypeError(
155
- `DISCUSS runtime options.playerBinding.${player} must be a non-empty string`,
147
+ `DECIDE runtime options.playerBinding.${player} must be a non-empty string`,
156
148
  );
157
149
  }
158
150
  }
@@ -161,140 +153,40 @@ function snapshotDiscussRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
161
153
  }
162
154
 
163
155
  const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
164
- ready: 'Idle hub awaiting a Boss discussion or review directive.',
165
- askHostInitial:
166
- 'Host proposes whether the Boss topic should become spec items or DRs.',
167
- askParticipantInitial:
168
- 'Participant independently proposes whether the Boss topic should become spec items or DRs.',
169
- hostInitialRound:
170
- 'Host reconciles the Participant proposal during initial discussion.',
171
- participantInitialRound:
172
- 'Participant reconciles the Host proposal during initial discussion.',
173
- hostWritesAgreement:
174
- 'Host writes the agreed spec items or DRs and updates the spec map.',
175
- commitInitialChanges:
176
- 'Committer commits the changes produced at the end of initial discussion.',
177
- reviewSpecInitialCommit:
178
- 'Participant reviews newly committed spec-item changes.',
179
- reviewSpecHostChanges:
180
- 'Participant reviews Host changes to spec items after findings.',
181
- reviewDrInitialCommit:
182
- 'Participant reviews newly committed decision-record changes.',
183
- reviewDrHostChanges:
184
- 'Participant reviews Host changes to decision records after findings.',
185
- reviewMixedInitialCommit:
186
- 'Participant reviews newly committed mixed spec-item and DR changes.',
187
- reviewMixedHostChanges:
188
- 'Participant reviews Host changes to mixed spec items and DRs after findings.',
189
- hostAddressesFindings:
190
- 'Host accepts or challenges review findings and stages repo changes.',
191
- participantAddressesRebuttals:
192
- 'Participant accepts or challenges Host rebuttals.',
193
- commitReviewedChanges:
194
- 'Committer commits reviewed changes once Participant raises no findings.',
195
- awaitBossReply: 'Waiting for Boss to answer a player question.',
196
- failed: 'The discussion workflow failed and is waiting for Boss recovery.',
197
- done: 'The discussion workflow completed with a reviewed commit.',
156
+ ready: 'Waiting for a topic to decide.',
157
+ askCoderProposal: 'Coder independently proposes a spec design.',
158
+ askReviewerProposal: 'Reviewer independently proposes a spec design.',
159
+ waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
160
+ waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
161
+ commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
162
+ awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
163
+ reviewCommit: 'REVIEW examines the committed proposal.',
164
+ failed: 'DECIDE failed and is waiting for a new topic.',
165
+ reportedReviewFailure:
166
+ 'DECIDE reports REVIEW’s failure and its last commit.',
167
+ done: 'DECIDE completed with an approved commit.',
198
168
  };
199
169
 
200
- const CAPTAIN_STATES = [
201
- { stateId: 'askHostInitial', player: 'Host', sourceItem: 'DISCUSS-1' },
202
- {
203
- stateId: 'askParticipantInitial',
204
- player: 'Participant',
205
- sourceItem: 'DISCUSS-2',
206
- },
207
- { stateId: 'hostInitialRound', player: 'Host', sourceItem: 'DISCUSS-3' },
208
- {
209
- stateId: 'participantInitialRound',
210
- player: 'Participant',
211
- sourceItem: 'DISCUSS-4',
212
- },
213
- { stateId: 'hostWritesAgreement', player: 'Host', sourceItem: 'DISCUSS-5' },
214
- {
215
- stateId: 'commitInitialChanges',
216
- player: 'Committer',
217
- sourceItem: 'DISCUSS-14',
218
- },
219
- {
220
- stateId: 'reviewSpecInitialCommit',
221
- player: 'Participant',
222
- sourceItem: 'DISCUSS-6',
223
- },
224
- {
225
- stateId: 'reviewSpecHostChanges',
226
- player: 'Participant',
227
- sourceItem: 'DISCUSS-7',
228
- },
229
- {
230
- stateId: 'reviewDrInitialCommit',
231
- player: 'Participant',
232
- sourceItem: 'DISCUSS-8',
233
- },
234
- {
235
- stateId: 'reviewDrHostChanges',
236
- player: 'Participant',
237
- sourceItem: 'DISCUSS-9',
238
- },
239
- {
240
- stateId: 'reviewMixedInitialCommit',
241
- player: 'Participant',
242
- sourceItem: 'DISCUSS-10',
243
- },
244
- {
245
- stateId: 'reviewMixedHostChanges',
246
- player: 'Participant',
247
- sourceItem: 'DISCUSS-11',
248
- },
249
- {
250
- stateId: 'hostAddressesFindings',
251
- player: 'Host',
252
- sourceItem: 'DISCUSS-12',
253
- },
254
- {
255
- stateId: 'participantAddressesRebuttals',
256
- player: 'Participant',
257
- sourceItem: 'DISCUSS-13',
258
- },
170
+ const PLAYER_STATES = [
171
+ { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
259
172
  {
260
- stateId: 'commitReviewedChanges',
261
- player: 'Committer',
262
- sourceItem: 'DISCUSS-15',
173
+ stateId: 'askReviewerProposal',
174
+ player: 'Reviewer',
175
+ sourceItem: 'DECIDE-2',
263
176
  },
177
+ { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
264
178
  ] as const;
265
179
 
266
- const CAPTAIN_STATE_IDS: ReadonlySet<string> = new Set(
267
- CAPTAIN_STATES.map((state) => state.stateId),
180
+ const PLAYER_STATE_IDS: ReadonlySet<string> = new Set(
181
+ PLAYER_STATES.map((state) => state.stateId),
268
182
  );
269
183
 
270
- const BOSS_INTERRUPT_TARGETS = [
271
- 'ready',
272
- 'initialProposalRound',
273
- 'reconciliationRound',
274
- 'hostWritesAgreement',
275
- 'commitInitialChanges',
276
- 'reviewSpecInitialCommit',
277
- 'reviewSpecHostChanges',
278
- 'reviewDrInitialCommit',
279
- 'reviewDrHostChanges',
280
- 'reviewMixedInitialCommit',
281
- 'reviewMixedHostChanges',
282
- 'hostAddressesFindings',
283
- 'participantAddressesRebuttals',
284
- 'commitReviewedChanges',
285
- 'failed',
286
- ] as const;
184
+ const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
287
185
 
288
186
  const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
289
187
  BOSS_INTERRUPT_TARGETS,
290
188
  );
291
189
 
292
- const REVIEW_SCOPES: ReadonlySet<string> = new Set([
293
- 'specItems',
294
- 'decisionRecords',
295
- 'mixed',
296
- ]);
297
-
298
190
  const TELEMETRY_TOPIC = 'playbook.fsm.state';
299
191
  const TRACE_TOPIC = 'playbook.trace';
300
192
 
@@ -303,19 +195,16 @@ const CONTINUATION_PREAMBLE =
303
195
 
304
196
  const PLACEHOLDER_FIELDS: ReadonlyArray<readonly [string, keyof PlayerInput]> =
305
197
  [
306
- ['<topic>', 'topic'],
307
- ['<participant-proposal>', 'participantProposal'],
308
- ['<host-previous-proposal>', 'hostProposal'],
309
- ['<host-proposal>', 'hostProposal'],
310
- ['<participant-previous-proposal>', 'participantProposal'],
311
- ['<agreement>', 'agreement'],
312
- ['<changes>', 'latestChanges'],
313
- ['<review-items>', 'reviewItems'],
314
- ['<rebuttals>', 'rebuttals'],
315
- ['<host-llm>', 'hostLlm'],
316
- ['<participant-llm>', 'participantLlm'],
198
+ ['<caller-topic>', 'callerTopic'],
199
+ ['<coder-llm>', 'coderLlm'],
317
200
  ];
318
201
 
202
+ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
203
+ 'coderProposal',
204
+ 'reviewerProposal',
205
+ 'coderOutput',
206
+ ]);
207
+
319
208
  function composePlayerPrompt(input: PlayerInput): string {
320
209
  const blocks: string[] = [];
321
210
 
@@ -333,13 +222,22 @@ function composePlayerPrompt(input: PlayerInput): string {
333
222
  );
334
223
  }
335
224
 
336
- let body = input.prompt;
225
+ const replacements = new Map<string, string>();
337
226
  for (const [placeholder, field] of PLACEHOLDER_FIELDS) {
338
227
  const value = input[field];
339
- if (typeof value === 'string') {
340
- body = body.replaceAll(placeholder, value);
341
- }
228
+ if (typeof value === 'string') replacements.set(placeholder, value);
342
229
  }
230
+ const body = input.prompt.replace(
231
+ /<caller-topic>|<coder-llm>/g,
232
+ (placeholder, offset: number, source: string) => {
233
+ const value = replacements.get(placeholder);
234
+ if (value === undefined) return placeholder;
235
+ const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
236
+ return source.slice(lineStart, offset) === '> '
237
+ ? value.replaceAll('\n', '\n> ')
238
+ : value;
239
+ },
240
+ );
343
241
 
344
242
  blocks.push(body);
345
243
  return blocks.join('\n\n');
@@ -350,12 +248,10 @@ function resolvePlayerId(
350
248
  binding: Record<PlayerName, string>,
351
249
  ): string {
352
250
  switch (input.player) {
353
- case 'Host':
354
- return binding.Host;
355
- case 'Participant':
356
- return binding.Participant;
357
- case 'Committer':
358
- return input.committerPlayer ?? binding.Host;
251
+ case 'Coder':
252
+ return binding.Coder;
253
+ case 'Reviewer':
254
+ return binding.Reviewer;
359
255
  default: {
360
256
  const exhaustive: never = input.player;
361
257
  throw new Error(`unknown player ${String(exhaustive)}`);
@@ -363,11 +259,8 @@ function resolvePlayerId(
363
259
  }
364
260
  }
365
261
 
366
- // A `result` description names required payload fields in an
367
- // "Output shall include ..." sentence. One sentence can name several
368
- // fields — DISCUSS-5's wroteChanges names both `latestChanges` and
369
- // `reviewScope` — so every backticked `field:` token in the sentence
370
- // span is required, not just the one adjacent to the phrase.
262
+ // A `result` description names required payload fields in its
263
+ // "Output shall include ..." sentence.
371
264
  function requiredFieldsFor(description: string): string[] {
372
265
  const fields: string[] = [];
373
266
  const sentence = /Output shall include([^.]*)/g;
@@ -492,7 +385,7 @@ function buildClassifierPrompt(
492
385
  },
493
386
  ): string {
494
387
  const lines: string[] = [];
495
- lines.push('You are the Boss-input classifier for the discuss playbook.');
388
+ lines.push('You are the Boss-input classifier for the decide playbook.');
496
389
  lines.push(
497
390
  'Classify the Boss message into exactly one FSM event, or into no event.',
498
391
  );
@@ -503,7 +396,7 @@ function buildClassifierPrompt(
503
396
  lines.push('Pending Boss questions:');
504
397
  for (const pending of ctx.pendingQuestions) {
505
398
  lines.push(
506
- `- ${pending.questionId} (${pending.player}, ${pending.sourceItem}): ${pending.question}`,
399
+ `- ${pending.questionId} (${pending.player}): ${pending.question}`,
507
400
  );
508
401
  }
509
402
  lines.push(
@@ -513,98 +406,77 @@ function buildClassifierPrompt(
513
406
  lines.push('');
514
407
  lines.push('Events and payload contracts:');
515
408
  lines.push(
516
- '- START_DISCUSSION: required topic string; optional hostLlm, participantLlm strings (identities normally come from run options).',
517
- );
518
- lines.push(
519
- '- START_REVIEW: required latestChanges string, required reviewScope string ("specItems" | "decisionRecords" | "mixed"), optional rebuttals string.',
409
+ `- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`,
520
410
  );
521
411
  lines.push(
522
- `- BOSS_INTERRUPT: required targetId string, one of ${BOSS_INTERRUPT_TARGETS.join(', ')}.`,
523
- );
524
- lines.push(
525
- '- BOSS_REPLY: required answer string and questionId when several questions are pending; valid only while at least one Boss question is pending.',
412
+ '- BOSS_REPLY: optional questionId when exactly one question is pending and required when several are pending; the runtime attaches the exact Boss text as answer.',
526
413
  );
414
+ lines.push('- NO_ACTION: no fields.');
527
415
  lines.push('');
528
416
  lines.push('Boss message:');
529
417
  lines.push(text);
530
418
  lines.push('');
531
419
  lines.push(
532
- 'Reply with a single JSON object: { "event": "<EVENT_TYPE or null>", ...payload fields }.',
420
+ 'Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.',
533
421
  );
534
- lines.push('Use null when no FSM action applies.');
422
+ lines.push('Use NO_ACTION when no FSM action applies.');
535
423
  return lines.join('\n');
536
424
  }
537
425
 
538
426
  function parseClassification(
539
427
  raw: string,
428
+ text: string,
540
429
  pendingQuestionIds: readonly string[] = [],
541
- ): DiscussEvent | null {
430
+ ): DecideEvent | { type: 'NO_ACTION' } | null {
542
431
  const obj = extractJson(raw);
543
432
  if (!obj) return null;
544
- const eventType = obj.event ?? obj.type;
433
+ const exactKeys = (...keys: string[]): boolean => {
434
+ const ownKeys = Reflect.ownKeys(obj);
435
+ return (
436
+ ownKeys.length === keys.length &&
437
+ ownKeys.every(
438
+ (key) => typeof key === 'string' && keys.includes(key),
439
+ )
440
+ );
441
+ };
442
+ const eventType = obj.type;
545
443
 
546
- if (eventType === 'START_DISCUSSION') {
547
- // hostLlm/participantLlm are optional on the FSM event: the identities
548
- // normally flow in via the machine input (run options).
549
- if (typeof obj.topic === 'string') {
550
- return {
551
- type: 'START_DISCUSSION',
552
- topic: obj.topic,
553
- ...(typeof obj.hostLlm === 'string' ? { hostLlm: obj.hostLlm } : {}),
554
- ...(typeof obj.participantLlm === 'string'
555
- ? { participantLlm: obj.participantLlm }
556
- : {}),
557
- };
558
- }
559
- return null;
560
- }
561
-
562
- if (eventType === 'START_REVIEW') {
563
- if (
564
- typeof obj.latestChanges === 'string' &&
565
- typeof obj.reviewScope === 'string' &&
566
- REVIEW_SCOPES.has(obj.reviewScope)
567
- ) {
568
- return {
569
- type: 'START_REVIEW',
570
- latestChanges: obj.latestChanges,
571
- reviewScope: obj.reviewScope as ReviewScope,
572
- ...(typeof obj.rebuttals === 'string'
573
- ? { rebuttals: obj.rebuttals }
574
- : {}),
575
- };
576
- }
577
- return null;
444
+ if (eventType === 'NO_ACTION') {
445
+ return exactKeys('type') ? { type: 'NO_ACTION' } : null;
578
446
  }
579
447
 
580
448
  if (eventType === 'BOSS_INTERRUPT') {
581
449
  if (
450
+ exactKeys('type', 'targetId') &&
582
451
  typeof obj.targetId === 'string' &&
583
452
  BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)
584
453
  ) {
585
- return { type: 'BOSS_INTERRUPT', targetId: obj.targetId as never };
454
+ return {
455
+ type: 'BOSS_INTERRUPT',
456
+ targetId: obj.targetId as 'independentProposals',
457
+ bossIntent: text,
458
+ };
586
459
  }
587
460
  return null;
588
461
  }
589
462
 
590
463
  if (eventType === 'BOSS_REPLY') {
591
- if (typeof obj.answer !== 'string' || pendingQuestionIds.length === 0) {
592
- return null;
593
- }
464
+ if (pendingQuestionIds.length === 0) return null;
594
465
  if (typeof obj.questionId === 'string') {
595
- return pendingQuestionIds.includes(obj.questionId)
466
+ return exactKeys('type', 'questionId') &&
467
+ pendingQuestionIds.includes(obj.questionId)
596
468
  ? {
597
469
  type: 'BOSS_REPLY',
598
470
  questionId: obj.questionId as never,
599
- answer: obj.answer,
471
+ answer: text,
600
472
  }
601
473
  : null;
602
474
  }
603
- return pendingQuestionIds.length === 1
475
+ return exactKeys('type') && pendingQuestionIds.length === 1
604
476
  ? {
605
477
  type: 'BOSS_REPLY',
606
478
  questionId: pendingQuestionIds[0] as never,
607
- answer: obj.answer,
479
+ answer: text,
608
480
  }
609
481
  : null;
610
482
  }
@@ -639,6 +511,18 @@ function buildAdjudicatorPrompt(
639
511
  for (const [guard, description] of Object.entries(input.result)) {
640
512
  lines.push(`- ${guard}: ${description}`);
641
513
  }
514
+ const runtimeOwnedFields = new Set<string>();
515
+ for (const description of Object.values(input.result)) {
516
+ for (const field of requiredFieldsFor(description)) {
517
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) runtimeOwnedFields.add(field);
518
+ }
519
+ }
520
+ if (runtimeOwnedFields.size > 0) {
521
+ lines.push('');
522
+ lines.push(
523
+ `The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`,
524
+ );
525
+ }
642
526
  lines.push('');
643
527
  lines.push(
644
528
  'Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.',
@@ -646,7 +530,11 @@ function buildAdjudicatorPrompt(
646
530
  return lines.join('\n');
647
531
  }
648
532
 
649
- function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
533
+ function parseAdjudication(
534
+ raw: string,
535
+ input: PlayerInput,
536
+ finalText: string,
537
+ ): PlayerOutput {
650
538
  const obj = extractJson(raw);
651
539
  if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
652
540
  throw new Error('adjudicator returned empty or malformed JSON');
@@ -659,15 +547,22 @@ function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
659
547
  );
660
548
  }
661
549
 
662
- // Per slc/link.md §Captain adjudication the judge answers
663
- // `{ guard, …payloadFields }` and the runtime validates the required
664
- // fields. Every payload field is carried through — dropping the
665
- // non-required ones would blind FSM fallbacks such as
666
- // `outputOf(event).reviewScope ?? context.reviewScope` on guards whose
667
- // description says "may include".
668
- const output = { ...obj, guard } as unknown as PlayerOutput;
669
- for (const field of requiredFieldsFor(input.result[guard])) {
670
- const value = output[field];
550
+ const requiredFields = requiredFieldsFor(input.result[guard]);
551
+ const allowedFields = new Set(['guard', ...requiredFields]);
552
+ for (const key of Reflect.ownKeys(obj)) {
553
+ if (typeof key !== 'string' || !allowedFields.has(key)) {
554
+ throw new Error(
555
+ `adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`,
556
+ );
557
+ }
558
+ }
559
+ const output: Record<string, unknown> = { guard };
560
+ for (const field of requiredFields) {
561
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
562
+ output[field] = finalText;
563
+ continue;
564
+ }
565
+ const value = obj[field];
671
566
  if (
672
567
  value === undefined ||
673
568
  value === null ||
@@ -677,19 +572,9 @@ function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
677
572
  `adjudicator response for guard "${guard}" missing required field "${field}"`,
678
573
  );
679
574
  }
575
+ output[field] = value;
680
576
  }
681
-
682
- if (
683
- 'reviewScope' in output &&
684
- typeof output.reviewScope === 'string' &&
685
- !REVIEW_SCOPES.has(output.reviewScope)
686
- ) {
687
- throw new Error(
688
- `adjudicator returned invalid reviewScope "${output.reviewScope}"`,
689
- );
690
- }
691
-
692
- return output;
577
+ return output as PlayerOutput;
693
578
  }
694
579
 
695
580
  function combineSignals(
@@ -722,9 +607,7 @@ function isEmptyFinalText(finalText: string | undefined): boolean {
722
607
  }
723
608
 
724
609
  function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
725
- if (!signal.aborted) return false;
726
- if (Object.is(error, signal.reason)) return true;
727
- return normalizeErrorCompact(error)?.name === 'AbortError';
610
+ return signal.aborted && Object.is(error, signal.reason);
728
611
  }
729
612
 
730
613
  function pendingQuestionsFromContext(
@@ -761,10 +644,8 @@ function pendingQuestionsFromContext(
761
644
  }
762
645
 
763
646
  const WAIT_STATE_RESUME_IDS: Readonly<Record<string, string>> = {
764
- waitHostInitialReply: 'askHostInitial',
765
- waitParticipantInitialReply: 'askParticipantInitial',
766
- waitHostReconciliationReply: 'hostInitialRound',
767
- waitParticipantReconciliationReply: 'participantInitialRound',
647
+ waitCoderProposalReply: 'askCoderProposal',
648
+ waitReviewerProposalReply: 'askReviewerProposal',
768
649
  };
769
650
 
770
651
  const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
@@ -773,7 +654,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
773
654
  ]);
774
655
 
775
656
  const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
776
- ...CAPTAIN_STATE_IDS,
657
+ ...PLAYER_STATE_IDS,
777
658
  ...WAIT_STATE_IDS,
778
659
  'failed',
779
660
  ]);
@@ -793,25 +674,36 @@ function questionForWaitState(
793
674
  : undefined;
794
675
  }
795
676
 
796
- function normalizedEventType(event: unknown): JsonValue {
797
- if (
798
- event !== null &&
799
- typeof event === 'object' &&
800
- !Array.isArray(event) &&
801
- 'type' in event
802
- ) {
803
- const type = (event as { type: unknown }).type;
804
- return typeof type === 'string' ? type : String(type);
677
+ const TRANSITION_EVENT_FIELDS: readonly string[] = [
678
+ 'callerTopic',
679
+ 'targetId',
680
+ 'bossIntent',
681
+ 'questionId',
682
+ 'answer',
683
+ ];
684
+
685
+ function normalizedTransitionEvent(event: unknown): JsonValue {
686
+ if (event === null || typeof event !== 'object') {
687
+ return snapshotJsonValue(event ?? null, 'FSM event');
805
688
  }
806
- if (
807
- event === null ||
808
- typeof event === 'string' ||
809
- typeof event === 'boolean' ||
810
- (typeof event === 'number' && Number.isFinite(event))
811
- ) {
812
- return event;
689
+ const source = event as Record<string, unknown>;
690
+ const descriptor: Record<string, JsonValue> = {};
691
+ if (typeof source.type === 'string') descriptor.type = source.type;
692
+ for (const field of TRANSITION_EVENT_FIELDS) {
693
+ if (typeof source[field] === 'string') {
694
+ descriptor[field] = source[field] as string;
695
+ }
696
+ }
697
+ if (source.output !== undefined) {
698
+ descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
699
+ }
700
+ if (source.error !== undefined) {
701
+ descriptor.error = snapshotJsonValue(
702
+ normalizeError(source.error),
703
+ 'FSM event error',
704
+ );
813
705
  }
814
- return String(event);
706
+ return snapshotJsonValue(descriptor, 'FSM event');
815
707
  }
816
708
 
817
709
  function telemetryPayload(
@@ -820,22 +712,15 @@ function telemetryPayload(
820
712
  event: unknown,
821
713
  context: Record<string, unknown>,
822
714
  ): JsonValue {
823
- const eventError =
824
- event !== null &&
825
- typeof event === 'object' &&
826
- !Array.isArray(event) &&
827
- 'error' in event
828
- ? normalizeErrorFull((event as { error: unknown }).error)
829
- : undefined;
830
715
  const pendingBossQuestions = pendingQuestionsFromContext(context);
716
+ const prior = previousState ?? state;
831
717
  const payload = {
832
- from: previousState?.value ?? null,
718
+ from: prior.value,
833
719
  to: state.value,
834
- event: normalizedEventType(event),
835
- previousState: previousState ?? null,
720
+ event: normalizedTransitionEvent(event),
721
+ previousState: prior,
836
722
  state,
837
723
  ...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
838
- ...(eventError !== undefined ? { error: eventError } : {}),
839
724
  ...(state.activeStateIds.includes('failed')
840
725
  ? { lastError: normalizeErrorFull(context.lastError) ?? null }
841
726
  : {}),
@@ -847,15 +732,13 @@ function telemetryPayload(
847
732
  export const createPlaybookRuntime: PlaybookRuntimeFactory<
848
733
  PlaybookRuntimeOptions
849
734
  > = (options) => {
850
- const boundOptions = snapshotDiscussRuntimeOptions(options);
735
+ const boundOptions = snapshotDecideRuntimeOptions(options);
851
736
  const binding: Record<PlayerName, string> = {
852
737
  ...DEFAULT_PLAYER_BINDING,
853
738
  ...(boundOptions.playerBinding ?? {}),
854
739
  };
855
- const fsmInput: DiscussInput = {
856
- host: boundOptions.host,
857
- participant: boundOptions.participant,
858
- committer: boundOptions.committer,
740
+ const fsmInput: DecideInput = {
741
+ coderLlm: boundOptions.coderLlm,
859
742
  };
860
743
 
861
744
  type SessionIdentity = Readonly<PlaybookSession>;
@@ -872,12 +755,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
872
755
  let turnSequence = 0;
873
756
  let judgeCallSequence = 0;
874
757
  let playerCallSequence = 0;
758
+ let playbookCallSequence = 0;
875
759
  let lifecycleStarted = false;
876
760
  let initInFlight: Promise<void> | undefined;
877
761
  let disposed = false;
878
762
  let disposalPromise: Promise<void> | undefined;
879
763
  let controlPlaneError: unknown;
764
+ let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
880
765
  const playerResumeTokens = new Map<string, string>();
766
+ const playbookCallTurnIds = new Map<string, number | undefined>();
881
767
  const inFlightPlayerIds = new Set<string>();
882
768
  const activeBoundaryCalls = new Set<Promise<unknown>>();
883
769
  const activeEmissionCalls = new Set<Promise<void>>();
@@ -899,6 +785,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
899
785
  ): void => {
900
786
  if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
901
787
  };
788
+ const latchInspectionError = (error: unknown): void => {
789
+ if (currentSignal !== undefined) {
790
+ latchControlPlaneError(error, currentSignal);
791
+ } else {
792
+ collectFailure(emissionFailures, error);
793
+ }
794
+ };
902
795
  const enqueue = (fn: () => Promise<void>): Promise<void> => {
903
796
  const queued = emissionQueue.add(fn);
904
797
  activeEmissionCalls.add(queued);
@@ -928,7 +821,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
928
821
  const failures = emissionFailures;
929
822
  emissionFailures = [];
930
823
  if (failures.length === 1) throw failures[0];
931
- throw new AggregateError(failures, 'discuss runtime emissions failed');
824
+ throw new AggregateError(failures, 'decide runtime emissions failed');
932
825
  };
933
826
  const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
934
827
  while (true) {
@@ -949,22 +842,92 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
949
842
  };
950
843
  const requirePorts = (): PlaybookPorts => {
951
844
  if (!ports) {
952
- throw new Error('discuss runtime: init(session) must be called first');
845
+ throw new Error('decide runtime: init(session) must be called first');
953
846
  }
954
847
  return ports;
955
848
  };
956
849
  const requireSessionIdentity = (): SessionIdentity => {
957
850
  if (!sessionIdentity) {
958
- throw new Error('discuss runtime: init(session) must be called first');
851
+ throw new Error('decide runtime: init(session) must be called first');
959
852
  }
960
853
  return sessionIdentity;
961
854
  };
855
+ const selectPlayerResume = (playerId: string): string | false => {
856
+ const session = requireSessionIdentity();
857
+ const selected = session.playerSessions
858
+ ? session.playerSessions.select(playerId)
859
+ : playerResumeTokens.get(playerId) ?? false;
860
+ if (
861
+ selected !== false &&
862
+ (typeof selected !== 'string' || selected.trim().length === 0)
863
+ ) {
864
+ throw new TypeError(
865
+ `player session store returned an invalid resume token for ${playerId}`,
866
+ );
867
+ }
868
+ return selected;
869
+ };
870
+ const updatePlayerResume = (
871
+ playerId: string,
872
+ resumeToken: string | undefined,
873
+ ): void => {
874
+ const session = requireSessionIdentity();
875
+ if (session.playerSessions) {
876
+ session.playerSessions.update(playerId, resumeToken);
877
+ } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
878
+ playerResumeTokens.set(playerId, resumeToken);
879
+ } else {
880
+ playerResumeTokens.delete(playerId);
881
+ }
882
+ };
883
+ const snapshotPlayerResumeTokens = (): Record<string, string> => {
884
+ const session = requireSessionIdentity();
885
+ const captured = snapshotJsonValue(
886
+ session.playerSessions
887
+ ? session.playerSessions.snapshot()
888
+ : Object.fromEntries(playerResumeTokens),
889
+ 'player session store snapshot',
890
+ );
891
+ if (!isPlainObject(captured)) {
892
+ throw new TypeError('player session store snapshot must be an object');
893
+ }
894
+ const tokens: Record<string, string> = {};
895
+ for (const [playerId, token] of Object.entries(captured)) {
896
+ if (playerId.trim().length === 0) {
897
+ throw new TypeError(
898
+ 'player session store snapshot player ids must be non-empty',
899
+ );
900
+ }
901
+ if (typeof token !== 'string' || token.trim().length === 0) {
902
+ throw new TypeError(
903
+ `player session store snapshot token for ${playerId} must be a non-empty string`,
904
+ );
905
+ }
906
+ tokens[playerId] = token;
907
+ }
908
+ return tokens;
909
+ };
910
+ const restorePlayerResumeTokens = (
911
+ tokens: Readonly<Record<string, string>>,
912
+ ): void => {
913
+ const session = requireSessionIdentity();
914
+ if (session.playerSessions) {
915
+ session.playerSessions.restore(tokens);
916
+ return;
917
+ }
918
+ playerResumeTokens.clear();
919
+ for (const [playerId, token] of Object.entries(tokens)) {
920
+ playerResumeTokens.set(playerId, token);
921
+ }
922
+ };
962
923
  const currentState = (): PlaybookState => {
963
924
  const live = actor;
964
925
  if (!live) {
965
- throw new Error('discuss runtime: actor is not initialized');
926
+ throw new Error('decide runtime: actor is not initialized');
966
927
  }
967
- return normalizePlaybookSnapshot(live.getSnapshot());
928
+ return normalizePlaybookSnapshot(live.getSnapshot(), {
929
+ pendingCall: nestedBridge.getPendingCall(),
930
+ });
968
931
  };
969
932
  const stateIdentity = (state: PlaybookState): { stateId?: string } => {
970
933
  return state.stateId === undefined ? {} : { stateId: state.stateId };
@@ -1070,7 +1033,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1070
1033
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
1071
1034
  };
1072
1035
  const queued = await judgeQueue.add(async () => {
1073
- // Keep the complete queue task pending until an active host promise
1036
+ // Keep the complete queue task pending until an active coder promise
1074
1037
  // settles. PQueue's signal option may reject add() while that task is
1075
1038
  // still running, which would let the turn drain race a late finish.
1076
1039
  signal.throwIfAborted();
@@ -1142,10 +1105,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1142
1105
  `resolved player "${playerId}" already has an in-flight call`,
1143
1106
  );
1144
1107
  }
1145
- inFlightPlayerIds.add(playerId);
1146
1108
  const prompt = composePlayerPrompt(input);
1147
- const resume: PlayerCallOptions['resume'] =
1148
- playerResumeTokens.get(playerId) ?? false;
1109
+ let resume: PlayerCallOptions['resume'];
1110
+ try {
1111
+ signal.throwIfAborted();
1112
+ resume = selectPlayerResume(playerId);
1113
+ } catch (error) {
1114
+ latchControlPlaneError(error, signal);
1115
+ throw error;
1116
+ }
1149
1117
  const callId = `player-${++playerCallSequence}`;
1150
1118
  const identity = {
1151
1119
  purpose: 'captain',
@@ -1167,6 +1135,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1167
1135
  },
1168
1136
  { turnId: currentTurnId, callId },
1169
1137
  );
1138
+ inFlightPlayerIds.add(playerId);
1170
1139
  try {
1171
1140
  await emitCallStarted(
1172
1141
  'player.call.started',
@@ -1183,13 +1152,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1183
1152
  requirePorts().callPlayer(playerId, prompt, signal, { resume }),
1184
1153
  );
1185
1154
  rawResult = await boundary;
1186
- // An XState sibling cancellation does not cancel an arbitrary host
1155
+ // An XState sibling cancellation does not cancel an arbitrary coder
1187
1156
  // promise. Re-check before a late resolution can mutate continuity or
1188
1157
  // masquerade as a successful boundary finish.
1189
1158
  signal.throwIfAborted();
1190
1159
  } catch (error) {
1191
1160
  // A rejected call produced no authoritative result, so the previous
1192
- // token remains untouched. This also covers a host promise that
1161
+ // token remains untouched. This also covers a coder promise that
1193
1162
  // resolves after its invocation signal was cancelled.
1194
1163
  latchControlPlaneError(error, signal);
1195
1164
  try {
@@ -1208,20 +1177,29 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1208
1177
  try {
1209
1178
  await emitFailure(error);
1210
1179
  } catch {
1211
- // The malformed host result remains authoritative.
1180
+ // The malformed coder result remains authoritative.
1212
1181
  }
1213
1182
  throw error;
1214
1183
  }
1215
1184
 
1216
1185
  // The resolved result is authoritative even on aborted/error status.
1217
1186
  // Update continuation state before interpreting that status.
1218
- if (
1219
- typeof result.resumeToken === 'string' &&
1220
- result.resumeToken.trim().length > 0
1221
- ) {
1222
- playerResumeTokens.set(playerId, result.resumeToken);
1223
- } else {
1224
- playerResumeTokens.delete(playerId);
1187
+ try {
1188
+ updatePlayerResume(
1189
+ playerId,
1190
+ typeof result.resumeToken === 'string' &&
1191
+ result.resumeToken.trim().length > 0
1192
+ ? result.resumeToken
1193
+ : undefined,
1194
+ );
1195
+ } catch (error) {
1196
+ latchControlPlaneError(error, signal);
1197
+ try {
1198
+ await emitFailure(error);
1199
+ } catch {
1200
+ // The continuation-store failure remains authoritative.
1201
+ }
1202
+ throw error;
1225
1203
  }
1226
1204
 
1227
1205
  // Keep this finish outside the boundary catch. A trace sink can record
@@ -1309,6 +1287,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1309
1287
  input.stateId,
1310
1288
  ),
1311
1289
  input,
1290
+ finalText,
1312
1291
  );
1313
1292
  } catch (error) {
1314
1293
  latchControlPlaneError(error, combined);
@@ -1317,89 +1296,162 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1317
1296
  },
1318
1297
  );
1319
1298
 
1320
- const providedMachine = discussMachine.provide({ actors: { player } });
1299
+ nestedBridge = createNestedPlaybookBridge<PlaybookInput>({
1300
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
1301
+ getBoundarySignal: () => currentSignal,
1302
+ callPlaybook: (request, signal) =>
1303
+ trackBoundaryCall(
1304
+ Promise.resolve(requirePorts().callPlaybook(request, signal)),
1305
+ ),
1306
+ emitStarted: async (event) => {
1307
+ playbookCallTurnIds.set(event.callId, currentTurnId);
1308
+ await emitTrace(
1309
+ 'playbook.call.started',
1310
+ {
1311
+ stateId: event.stateId,
1312
+ playbookId: event.playbookId,
1313
+ text: event.text,
1314
+ },
1315
+ {
1316
+ ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
1317
+ callId: event.callId,
1318
+ },
1319
+ );
1320
+ },
1321
+ emitFinished: async (event) => {
1322
+ const turnId = playbookCallTurnIds.get(event.callId);
1323
+ try {
1324
+ await emitTrace(
1325
+ 'playbook.call.finished',
1326
+ {
1327
+ stateId: event.stateId,
1328
+ playbookId: event.playbookId,
1329
+ text: event.text,
1330
+ result: event.result,
1331
+ },
1332
+ {
1333
+ ...(turnId === undefined ? {} : { turnId }),
1334
+ callId: event.callId,
1335
+ },
1336
+ );
1337
+ } finally {
1338
+ playbookCallTurnIds.delete(event.callId);
1339
+ }
1340
+ },
1341
+ drain: flush,
1342
+ bindResumeSignal: (signal) => {
1343
+ currentSignal = signal;
1344
+ },
1345
+ onControlPlaneError: (error) => {
1346
+ const signal = currentSignal;
1347
+ if (!signal || !isAbortFailure(error, signal)) {
1348
+ controlPlaneError ??= error;
1349
+ }
1350
+ },
1351
+ onBackgroundError: (error) => {
1352
+ collectFailure(emissionFailures, error);
1353
+ },
1354
+ });
1355
+
1356
+ const providedMachine = decideMachine.provide({
1357
+ actors: { player, playbook: nestedBridge.actorLogic },
1358
+ });
1321
1359
 
1322
1360
  const inspect = (event: InspectionEvent): void => {
1323
1361
  if (event.type !== '@xstate.snapshot') return;
1324
1362
  if (actor === undefined || event.actorRef !== actor) return;
1325
1363
  if (suppressInspectionEmissions) return;
1326
- const snapshot = event.snapshot as SnapshotFrom<typeof discussMachine>;
1327
- const state = normalizePlaybookSnapshot(snapshot);
1328
- const prior = previousState;
1329
- previousState = state;
1330
-
1331
- const runtimePorts = ports;
1332
- if (!runtimePorts) return;
1333
- const context = snapshot.context as Record<string, unknown>;
1334
- const fsmPayload = telemetryPayload(prior, state, event.event, context);
1335
- const describedFsmPayload = snapshotJsonValue(
1336
- fsmPayload,
1337
- 'described FSM telemetry',
1338
- );
1364
+ try {
1365
+ const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
1366
+ const state = normalizePlaybookSnapshot(snapshot);
1367
+ const prior = previousState ?? state;
1368
+ const context = snapshot.context as unknown as Record<string, unknown>;
1369
+ const fsmPayload = telemetryPayload(prior, state, event.event, context);
1370
+ const describedFsmPayload = snapshotJsonValue(
1371
+ fsmPayload,
1372
+ 'described FSM telemetry',
1373
+ );
1339
1374
 
1340
- void enqueueTracedEmission(
1341
- 'fsm.transition',
1342
- fsmPayload,
1343
- { turnId: currentTurnId },
1344
- (emissionPorts) =>
1345
- emissionPorts.emitTelemetry({
1346
- topic: TELEMETRY_TOPIC,
1347
- payload: describedFsmPayload,
1348
- }),
1349
- ).catch(() => undefined);
1350
-
1351
- const priorIds = new Set(prior?.activeStateIds ?? []);
1352
- const pendingQuestions = pendingQuestionsFromContext(context);
1353
- const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1354
- STATUS_STATE_IDS.has(stateId),
1355
- );
1356
- const scheduleStatus = (
1357
- message: string,
1358
- stateId: string,
1359
- data?: JsonValue,
1360
- ): void => {
1361
- const tracePayload = {
1362
- ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1363
- message,
1364
- state,
1365
- ...(data !== undefined ? { data } : {}),
1366
- };
1367
- assertJsonSafe(tracePayload);
1368
1375
  void enqueueTracedEmission(
1369
- 'status.emitted',
1370
- tracePayload,
1376
+ 'fsm.transition',
1377
+ fsmPayload,
1371
1378
  { turnId: currentTurnId },
1372
- (emissionPorts) => emissionPorts.emitStatus(message, data),
1379
+ (emissionPorts) =>
1380
+ emissionPorts.emitTelemetry({
1381
+ topic: TELEMETRY_TOPIC,
1382
+ payload: describedFsmPayload,
1383
+ }),
1373
1384
  ).catch(() => undefined);
1374
- };
1375
1385
 
1376
- for (const activeStateId of state.activeStateIds) {
1377
- if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
1378
- continue;
1379
- }
1380
- if (WAIT_STATE_IDS.has(activeStateId)) {
1381
- const pending = questionForWaitState(activeStateId, pendingQuestions);
1382
- if (pending) {
1383
- scheduleStatus(
1384
- `${pending.player} asks: ${pending.question}`,
1385
- activeStateId,
1386
- );
1387
- scheduleStatus(
1388
- `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1386
+ const priorIds = new Set(previousState?.activeStateIds ?? []);
1387
+ previousState = state;
1388
+ const pendingQuestions = pendingQuestionsFromContext(context);
1389
+ const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1390
+ STATUS_STATE_IDS.has(stateId),
1391
+ );
1392
+ const scheduleStatus = (
1393
+ message: string,
1394
+ stateId: string,
1395
+ data?: JsonValue,
1396
+ ): void => {
1397
+ const tracePayload = {
1398
+ ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1399
+ message,
1400
+ state,
1401
+ ...(data !== undefined ? { data } : {}),
1402
+ };
1403
+ assertJsonSafe(tracePayload);
1404
+ void enqueueTracedEmission(
1405
+ 'status.emitted',
1406
+ tracePayload,
1407
+ { turnId: currentTurnId },
1408
+ (emissionPorts) => emissionPorts.emitStatus(message, data),
1409
+ ).catch(() => undefined);
1410
+ };
1411
+
1412
+ for (const activeStateId of state.activeStateIds) {
1413
+ if (
1414
+ priorIds.has(activeStateId) ||
1415
+ !STATUS_STATE_IDS.has(activeStateId)
1416
+ ) {
1417
+ continue;
1418
+ }
1419
+ if (WAIT_STATE_IDS.has(activeStateId)) {
1420
+ const pending = questionForWaitState(
1389
1421
  activeStateId,
1422
+ pendingQuestions,
1390
1423
  );
1424
+ if (pending) {
1425
+ scheduleStatus(
1426
+ `${pending.player} asks: ${pending.question}`,
1427
+ activeStateId,
1428
+ );
1429
+ scheduleStatus(
1430
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1431
+ activeStateId,
1432
+ );
1433
+ }
1434
+ continue;
1391
1435
  }
1392
- continue;
1436
+ const lastError =
1437
+ activeStateId === 'failed'
1438
+ ? normalizeErrorCompact(context.lastError)
1439
+ : undefined;
1440
+ const description = STATE_DESCRIPTIONS[activeStateId];
1441
+ if (description === undefined) continue;
1442
+ const playerState = PLAYER_STATES.find(
1443
+ (candidate) => candidate.stateId === activeStateId,
1444
+ );
1445
+ scheduleStatus(
1446
+ playerState === undefined
1447
+ ? '◆ workflow failed; awaiting Boss recovery.'
1448
+ : `⤷ ${playerState.player}: ${description}`,
1449
+ activeStateId,
1450
+ lastError === undefined ? undefined : { lastError },
1451
+ );
1393
1452
  }
1394
- const lastError =
1395
- activeStateId === 'failed'
1396
- ? normalizeErrorCompact(context.lastError)
1397
- : undefined;
1398
- scheduleStatus(
1399
- STATE_DESCRIPTIONS[activeStateId] ?? activeStateId,
1400
- activeStateId,
1401
- lastError === undefined ? undefined : { lastError },
1402
- );
1453
+ } catch (error) {
1454
+ latchInspectionError(error);
1403
1455
  }
1404
1456
  };
1405
1457
 
@@ -1413,7 +1465,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1413
1465
  ? {}
1414
1466
  : {
1415
1467
  snapshot: machineSnapshot as unknown as SnapshotFrom<
1416
- typeof discussMachine
1468
+ typeof decideMachine
1417
1469
  >,
1418
1470
  }),
1419
1471
  inspect,
@@ -1442,20 +1494,31 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1442
1494
 
1443
1495
  const driveToQuiescence = async (): Promise<void> => {
1444
1496
  const live = actor;
1445
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1446
- await waitForPlaybookQuiescence(live);
1497
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1498
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
1447
1499
  };
1448
1500
 
1449
1501
  const classify = async (
1450
1502
  text: string,
1451
1503
  signal: AbortSignal,
1452
- ): Promise<DiscussEvent | null> => {
1504
+ ): Promise<DecideEvent | { type: 'NO_ACTION' } | null> => {
1453
1505
  const live = actor;
1454
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1455
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof discussMachine>;
1456
- const context = snapshot.context as Record<string, unknown>;
1457
- const state = normalizePlaybookSnapshot(snapshot);
1506
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1507
+ const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
1508
+ const context = snapshot.context as unknown as Record<string, unknown>;
1509
+ const state = normalizePlaybookSnapshot(snapshot, {
1510
+ pendingCall: nestedBridge.getPendingCall(),
1511
+ });
1458
1512
  const pendingQuestions = pendingQuestionsFromContext(context);
1513
+ if (
1514
+ pendingQuestions.length === 0 &&
1515
+ (snapshot.status === 'done' ||
1516
+ state.activeStateIds.includes('ready') ||
1517
+ state.activeStateIds.includes('failed'))
1518
+ ) {
1519
+ return { type: 'START_DECIDE', callerTopic: text };
1520
+ }
1521
+ if (pendingQuestions.length === 0) return null;
1459
1522
  const prompt = buildClassifierPrompt(text, {
1460
1523
  state,
1461
1524
  pendingQuestions,
@@ -1468,16 +1531,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1468
1531
  );
1469
1532
  return parseClassification(
1470
1533
  raw,
1534
+ text,
1471
1535
  pendingQuestions.map(({ questionId }) => questionId),
1472
1536
  );
1473
1537
  };
1474
1538
 
1475
1539
  const resultForSnapshot = (signal?: AbortSignal): PlaybookRunResult => {
1476
1540
  const live = actor;
1477
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1478
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof discussMachine>;
1479
- const state = normalizePlaybookSnapshot(snapshot);
1480
- const context = snapshot.context as Record<string, unknown>;
1541
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1542
+ const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
1543
+ const pendingCall = nestedBridge.getPendingCall();
1544
+ const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1545
+ const context = snapshot.context as unknown as Record<string, unknown>;
1481
1546
  if (signal?.aborted) {
1482
1547
  return {
1483
1548
  outcome: 'aborted',
@@ -1504,7 +1569,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1504
1569
  if (snapshot.status === 'error') {
1505
1570
  throw (
1506
1571
  (snapshot as { error?: unknown }).error ??
1507
- new Error('discuss runtime actor entered error status')
1572
+ new Error('decide runtime actor entered error status')
1508
1573
  );
1509
1574
  }
1510
1575
  if (state.activeStateIds.includes('failed')) {
@@ -1515,6 +1580,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1515
1580
  ...(error === undefined ? {} : { error }),
1516
1581
  };
1517
1582
  }
1583
+ if (pendingCall) {
1584
+ return { outcome: 'suspended', state, pendingCall };
1585
+ }
1518
1586
  return { outcome: 'quiescent', state };
1519
1587
  };
1520
1588
 
@@ -1577,6 +1645,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1577
1645
  turnSequence = 0;
1578
1646
  judgeCallSequence = 0;
1579
1647
  playerCallSequence = 0;
1648
+ playbookCallSequence = 0;
1649
+ playbookCallTurnIds.clear();
1580
1650
  lifecycleStarted = false;
1581
1651
  };
1582
1652
 
@@ -1589,7 +1659,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1589
1659
  disposalPromise !== undefined
1590
1660
  ) {
1591
1661
  throw new Error(
1592
- 'discuss runtime: init(session) may only be called once',
1662
+ 'decide runtime: init(session) may only be called once',
1593
1663
  );
1594
1664
  }
1595
1665
  const identity = snapshotPlaybookSession(session);
@@ -1623,8 +1693,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1623
1693
  // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1624
1694
  // Defined only at a safe capture point — initialized, not disposing
1625
1695
  // or disposed, no active public boundary, and the actor quiescent
1626
- // with status `active`. DISCUSS never opens nested playbook calls,
1627
- // so no pending-call guard applies.
1696
+ // with status `active` and no pending nested REVIEW call.
1628
1697
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1629
1698
  if (
1630
1699
  !actor ||
@@ -1637,25 +1706,26 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1637
1706
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1638
1707
  return undefined;
1639
1708
  }
1709
+ if (nestedBridge.getPendingCall()) return undefined;
1640
1710
  const state = currentState();
1641
1711
  if (state.status !== 'active' || !state.quiescent) return undefined;
1642
1712
  const machine = detachPersistedMachineSnapshot(
1643
1713
  actor.getPersistedSnapshot(),
1644
1714
  );
1645
1715
  const context = (
1646
- actor.getSnapshot() as SnapshotFrom<typeof discussMachine>
1647
- ).context as Record<string, unknown>;
1716
+ actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1717
+ ).context as unknown as Record<string, unknown>;
1648
1718
  return {
1649
1719
  schemaVersion: 1,
1650
1720
  playbookId: sessionIdentity.playbookId,
1651
1721
  machine,
1652
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1722
+ playerResumeTokens: snapshotPlayerResumeTokens(),
1653
1723
  sequences: {
1654
1724
  trace: traceSequence,
1655
1725
  turn: turnSequence,
1656
1726
  judgeCall: judgeCallSequence,
1657
1727
  playerCall: playerCallSequence,
1658
- playbookCall: 0,
1728
+ playbookCall: playbookCallSequence,
1659
1729
  },
1660
1730
  state,
1661
1731
  pendingBossQuestions: pendingQuestionsFromContext(context).map(
@@ -1685,7 +1755,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1685
1755
  disposalPromise !== undefined
1686
1756
  ) {
1687
1757
  throw new Error(
1688
- 'discuss runtime: restore(session, snapshot) may only be called once',
1758
+ 'decide runtime: restore(session, snapshot) may only be called once',
1689
1759
  );
1690
1760
  }
1691
1761
  const identity = snapshotPlaybookSession(session);
@@ -1701,32 +1771,45 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1701
1771
  lifecycleStarted = true;
1702
1772
  ports = identity.ports;
1703
1773
  sessionIdentity = identity;
1774
+ let priorExternalPlayerTokens:
1775
+ | Readonly<Record<string, string>>
1776
+ | undefined;
1704
1777
  try {
1705
1778
  traceSequence = boundSnapshot.sequences.trace;
1706
1779
  turnSequence = boundSnapshot.sequences.turn;
1707
1780
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1708
1781
  playerCallSequence = boundSnapshot.sequences.playerCall;
1709
- playerResumeTokens.clear();
1710
- for (const [playerId, token] of Object.entries(
1711
- boundSnapshot.playerResumeTokens,
1712
- )) {
1713
- playerResumeTokens.set(playerId, token);
1782
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1783
+ if (identity.playerSessions) {
1784
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1714
1785
  }
1786
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1715
1787
  suppressInspectionEmissions = true;
1716
1788
  createRuntimeActor(boundSnapshot.machine);
1717
1789
  actor?.start();
1718
1790
  const restoredState = currentState();
1719
1791
  if (restoredState.status !== 'active') {
1720
1792
  throw new Error(
1721
- `discuss runtime: restored actor status is ${restoredState.status}, expected active`,
1793
+ `decide runtime: restored actor status is ${restoredState.status}, expected active`,
1722
1794
  );
1723
1795
  }
1724
1796
  suppressInspectionEmissions = false;
1725
1797
  previousState = restoredState;
1726
1798
  await flush();
1727
1799
  } catch (error) {
1800
+ let failure = error;
1801
+ if (priorExternalPlayerTokens !== undefined) {
1802
+ try {
1803
+ identity.playerSessions!.restore(priorExternalPlayerTokens);
1804
+ } catch (rollbackError) {
1805
+ failure = new AggregateError(
1806
+ [error, rollbackError],
1807
+ 'DECIDE restore and player continuation rollback failed',
1808
+ );
1809
+ }
1810
+ }
1728
1811
  await cleanupFailedStart({ emitDisposal: false });
1729
- throw error;
1812
+ throw failure;
1730
1813
  } finally {
1731
1814
  finishInitialization();
1732
1815
  if (initInFlight === initialization) initInFlight = undefined;
@@ -1738,16 +1821,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1738
1821
  signal: AbortSignal;
1739
1822
  }): Promise<PlaybookRunResult> {
1740
1823
  if (disposalPromise !== undefined) {
1741
- throw new Error('discuss runtime: runtime is disposing or disposed');
1824
+ throw new Error('decide runtime: runtime is disposing or disposed');
1742
1825
  }
1743
1826
  requirePorts();
1744
1827
  if (!actor) {
1745
1828
  throw new Error(
1746
- 'discuss runtime: init(session) must be called before handleBossInput',
1829
+ 'decide runtime: init(session) must be called before handleBossInput',
1747
1830
  );
1748
1831
  }
1749
- if (currentTurnId !== undefined) {
1750
- throw new Error('discuss runtime: another runtime turn is active');
1832
+ if (currentSignal !== undefined) {
1833
+ throw new Error('decide runtime: another runtime turn is active');
1751
1834
  }
1752
1835
 
1753
1836
  const turnId = ++turnSequence;
@@ -1768,6 +1851,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1768
1851
  const state = currentState();
1769
1852
  await emitBoundaryStatus('No playbook action classified.', state);
1770
1853
  result = { outcome: 'no-action', state };
1854
+ } else if (event.type === 'NO_ACTION') {
1855
+ result = { outcome: 'no-action', state: currentState() };
1771
1856
  } else {
1772
1857
  await emitBoundaryStatus(event.type, currentState());
1773
1858
  if (actor.getSnapshot().status === 'done') {
@@ -1852,36 +1937,77 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1852
1937
 
1853
1938
  if (failures.length === 1) throw failures[0];
1854
1939
  if (failures.length > 1) {
1855
- throw new AggregateError(failures, 'discuss runtime turn failed');
1940
+ throw new AggregateError(failures, 'decide runtime turn failed');
1856
1941
  }
1857
1942
  return result;
1858
1943
  },
1859
1944
 
1860
1945
  async resumePlaybookCall({
1861
1946
  callId,
1947
+ result: childResult,
1948
+ signal,
1862
1949
  }: {
1863
1950
  callId: string;
1864
1951
  result: PlaybookCallResult;
1865
1952
  signal: AbortSignal;
1866
1953
  }): Promise<PlaybookRunResult> {
1867
1954
  if (disposalPromise !== undefined) {
1868
- throw new Error('discuss runtime: runtime is disposing or disposed');
1955
+ throw new Error('decide runtime: runtime is disposing or disposed');
1869
1956
  }
1870
1957
  requirePorts();
1871
1958
  if (!actor) {
1872
1959
  throw new Error(
1873
- 'discuss runtime: init(session) must be called before resumePlaybookCall',
1960
+ 'decide runtime: init(session) must be called before resumePlaybookCall',
1874
1961
  );
1875
1962
  }
1876
- throw new Error(`unknown or stale playbook call id ${callId}`);
1963
+ if (currentSignal !== undefined) {
1964
+ throw new Error('decide runtime: another runtime turn is active');
1965
+ }
1966
+ currentTurnId = playbookCallTurnIds.get(callId);
1967
+ currentSignal = signal;
1968
+ controlPlaneError = undefined;
1969
+ let runResult: PlaybookRunResult | undefined;
1970
+ let operationError: unknown;
1971
+ try {
1972
+ await nestedBridge.resume({
1973
+ callId,
1974
+ result: childResult,
1975
+ signal,
1976
+ });
1977
+ } catch (error) {
1978
+ operationError = error;
1979
+ }
1980
+ try {
1981
+ await waitForPlaybookQuiescence(actor, {
1982
+ pendingCalls: nestedBridge,
1983
+ });
1984
+ runResult = resultForSnapshot(signal);
1985
+ } catch (error) {
1986
+ operationError ??= error;
1987
+ }
1988
+ let drainError: unknown;
1989
+ try {
1990
+ await drainBoundaryCallsAndEmissions();
1991
+ } catch (error) {
1992
+ drainError = error;
1993
+ }
1994
+ const failure = controlPlaneError ?? drainError ?? operationError;
1995
+ currentSignal = undefined;
1996
+ currentTurnId = undefined;
1997
+ controlPlaneError = undefined;
1998
+ if (failure !== undefined) throw failure;
1999
+ if (runResult === undefined) {
2000
+ throw new Error('decide runtime: playbook resume produced no result');
2001
+ }
2002
+ return runResult;
1877
2003
  },
1878
2004
 
1879
2005
  dispose(): Promise<void> {
1880
2006
  if (disposalPromise !== undefined) return disposalPromise;
1881
- if (currentTurnId !== undefined) {
2007
+ if (currentSignal !== undefined) {
1882
2008
  return Promise.reject(
1883
2009
  new Error(
1884
- 'discuss runtime: cannot dispose while a runtime turn is active',
2010
+ 'decide runtime: cannot dispose while a runtime turn is active',
1885
2011
  ),
1886
2012
  );
1887
2013
  }
@@ -1895,6 +2021,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1895
2021
  const finalState = currentState();
1896
2022
  const failures: unknown[] = [];
1897
2023
  stopActor();
2024
+ try {
2025
+ await nestedBridge.dispose();
2026
+ } catch (error) {
2027
+ collectFailure(failures, error);
2028
+ }
1898
2029
  try {
1899
2030
  await drainBoundaryCallsAndEmissions();
1900
2031
  } catch (error) {
@@ -1914,6 +2045,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1914
2045
  collectFailure(failures, error);
1915
2046
  } finally {
1916
2047
  playerResumeTokens.clear();
2048
+ playbookCallTurnIds.clear();
1917
2049
  inFlightPlayerIds.clear();
1918
2050
  activeBoundaryCalls.clear();
1919
2051
  activeEmissionCalls.clear();
@@ -1930,7 +2062,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1930
2062
  }
1931
2063
  if (failures.length === 1) throw failures[0];
1932
2064
  if (failures.length > 1) {
1933
- throw new AggregateError(failures, 'discuss runtime disposal failed');
2065
+ throw new AggregateError(failures, 'decide runtime disposal failed');
1934
2066
  }
1935
2067
  })();
1936
2068
  return disposalPromise;
@@ -1952,10 +2084,10 @@ export const _internal = {
1952
2084
  normalizeErrorCompact,
1953
2085
  normalizeErrorFull,
1954
2086
  DEFAULT_PLAYER_BINDING,
1955
- ALIAS_RESOLUTION,
1956
2087
  STATE_DESCRIPTIONS,
1957
- CAPTAIN_STATES,
1958
- CAPTAIN_STATE_IDS,
2088
+ PLAYER_STATES,
2089
+ PLAYER_STATE_IDS,
2090
+ VERBATIM_PAYLOAD_FIELDS,
1959
2091
  BOSS_INTERRUPT_TARGETS,
1960
2092
  CONTINUATION_PREAMBLE,
1961
2093
  TELEMETRY_TOPIC,