@sublang/playbook 4.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 (73) hide show
  1. package/README.md +69 -122
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +43 -26
  4. package/docs/configuration.md +63 -18
  5. package/docs/embedding.md +24 -16
  6. package/package.json +43 -22
  7. package/reference/sdlc/captain.md +70 -83
  8. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  9. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  10. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  11. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  12. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  13. package/reference/sdlc/captain.playbook/captain.playbook.js +466 -976
  14. package/reference/sdlc/captain.playbook/captain.playbook.ts +698 -1001
  15. package/reference/sdlc/code.md +55 -97
  16. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  18. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  19. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  20. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  21. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  22. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  23. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  24. package/reference/sdlc/code.playbook/code.playbook.js +69 -639
  25. package/reference/sdlc/code.playbook/code.playbook.ts +90 -850
  26. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  27. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  28. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  29. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  30. package/reference/sdlc/code.playbook/playbook-captain.js +1877 -251
  31. package/reference/sdlc/code.playbook/playbook-captain.ts +2385 -352
  32. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  33. package/reference/sdlc/decide.md +54 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  35. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  36. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  37. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  38. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  39. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +511 -370
  40. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +616 -451
  41. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  42. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  43. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  44. package/reference/sdlc/review.md +81 -0
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  47. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  48. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  50. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  51. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  52. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  53. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  54. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  55. package/slc/gears2fsm.md +67 -6
  56. package/slc/link.md +339 -25
  57. package/slc/text2gears.md +22 -2
  58. package/src/runtime.d.ts +36 -1
  59. package/src/runtime.ts +59 -0
  60. package/src/xstate-playbook-runtime.d.ts +96 -7
  61. package/src/xstate-playbook-runtime.js +1018 -49
  62. package/src/xstate-playbook-runtime.ts +1283 -59
  63. package/src/xstate-runtime.js +25 -0
  64. package/src/xstate-runtime.ts +51 -0
  65. package/reference/sdlc/discuss.md +0 -93
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  68. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  69. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  70. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  71. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  72. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  73. 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' },
170
+ const PLAYER_STATES = [
171
+ { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
208
172
  {
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
- },
259
- {
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(
@@ -713,10 +598,16 @@ function normalizeErrorFull(
713
598
  return err === undefined || err === null ? undefined : normalizeError(err);
714
599
  }
715
600
 
601
+ // DR-028's unified empty predicate: a missing, empty, or whitespace-only
602
+ // `finalText` on an `ok` player result is the one shape that earns exactly
603
+ // one corrective re-ask before the existing failure path applies. Mirrors
604
+ // the shared engine's predicate (PBRT-9).
605
+ function isEmptyFinalText(finalText: string | undefined): boolean {
606
+ return finalText === undefined || finalText.trim().length === 0;
607
+ }
608
+
716
609
  function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
717
- if (!signal.aborted) return false;
718
- if (Object.is(error, signal.reason)) return true;
719
- return normalizeErrorCompact(error)?.name === 'AbortError';
610
+ return signal.aborted && Object.is(error, signal.reason);
720
611
  }
721
612
 
722
613
  function pendingQuestionsFromContext(
@@ -753,10 +644,8 @@ function pendingQuestionsFromContext(
753
644
  }
754
645
 
755
646
  const WAIT_STATE_RESUME_IDS: Readonly<Record<string, string>> = {
756
- waitHostInitialReply: 'askHostInitial',
757
- waitParticipantInitialReply: 'askParticipantInitial',
758
- waitHostReconciliationReply: 'hostInitialRound',
759
- waitParticipantReconciliationReply: 'participantInitialRound',
647
+ waitCoderProposalReply: 'askCoderProposal',
648
+ waitReviewerProposalReply: 'askReviewerProposal',
760
649
  };
761
650
 
762
651
  const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
@@ -765,7 +654,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
765
654
  ]);
766
655
 
767
656
  const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
768
- ...CAPTAIN_STATE_IDS,
657
+ ...PLAYER_STATE_IDS,
769
658
  ...WAIT_STATE_IDS,
770
659
  'failed',
771
660
  ]);
@@ -785,25 +674,36 @@ function questionForWaitState(
785
674
  : undefined;
786
675
  }
787
676
 
788
- function normalizedEventType(event: unknown): JsonValue {
789
- if (
790
- event !== null &&
791
- typeof event === 'object' &&
792
- !Array.isArray(event) &&
793
- 'type' in event
794
- ) {
795
- const type = (event as { type: unknown }).type;
796
- 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');
797
688
  }
798
- if (
799
- event === null ||
800
- typeof event === 'string' ||
801
- typeof event === 'boolean' ||
802
- (typeof event === 'number' && Number.isFinite(event))
803
- ) {
804
- 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
+ );
805
705
  }
806
- return String(event);
706
+ return snapshotJsonValue(descriptor, 'FSM event');
807
707
  }
808
708
 
809
709
  function telemetryPayload(
@@ -812,22 +712,15 @@ function telemetryPayload(
812
712
  event: unknown,
813
713
  context: Record<string, unknown>,
814
714
  ): JsonValue {
815
- const eventError =
816
- event !== null &&
817
- typeof event === 'object' &&
818
- !Array.isArray(event) &&
819
- 'error' in event
820
- ? normalizeErrorFull((event as { error: unknown }).error)
821
- : undefined;
822
715
  const pendingBossQuestions = pendingQuestionsFromContext(context);
716
+ const prior = previousState ?? state;
823
717
  const payload = {
824
- from: previousState?.value ?? null,
718
+ from: prior.value,
825
719
  to: state.value,
826
- event: normalizedEventType(event),
827
- previousState: previousState ?? null,
720
+ event: normalizedTransitionEvent(event),
721
+ previousState: prior,
828
722
  state,
829
723
  ...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
830
- ...(eventError !== undefined ? { error: eventError } : {}),
831
724
  ...(state.activeStateIds.includes('failed')
832
725
  ? { lastError: normalizeErrorFull(context.lastError) ?? null }
833
726
  : {}),
@@ -839,15 +732,13 @@ function telemetryPayload(
839
732
  export const createPlaybookRuntime: PlaybookRuntimeFactory<
840
733
  PlaybookRuntimeOptions
841
734
  > = (options) => {
842
- const boundOptions = snapshotDiscussRuntimeOptions(options);
735
+ const boundOptions = snapshotDecideRuntimeOptions(options);
843
736
  const binding: Record<PlayerName, string> = {
844
737
  ...DEFAULT_PLAYER_BINDING,
845
738
  ...(boundOptions.playerBinding ?? {}),
846
739
  };
847
- const fsmInput: DiscussInput = {
848
- host: boundOptions.host,
849
- participant: boundOptions.participant,
850
- committer: boundOptions.committer,
740
+ const fsmInput: DecideInput = {
741
+ coderLlm: boundOptions.coderLlm,
851
742
  };
852
743
 
853
744
  type SessionIdentity = Readonly<PlaybookSession>;
@@ -864,12 +755,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
864
755
  let turnSequence = 0;
865
756
  let judgeCallSequence = 0;
866
757
  let playerCallSequence = 0;
758
+ let playbookCallSequence = 0;
867
759
  let lifecycleStarted = false;
868
760
  let initInFlight: Promise<void> | undefined;
869
761
  let disposed = false;
870
762
  let disposalPromise: Promise<void> | undefined;
871
763
  let controlPlaneError: unknown;
764
+ let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
872
765
  const playerResumeTokens = new Map<string, string>();
766
+ const playbookCallTurnIds = new Map<string, number | undefined>();
873
767
  const inFlightPlayerIds = new Set<string>();
874
768
  const activeBoundaryCalls = new Set<Promise<unknown>>();
875
769
  const activeEmissionCalls = new Set<Promise<void>>();
@@ -891,6 +785,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
891
785
  ): void => {
892
786
  if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
893
787
  };
788
+ const latchInspectionError = (error: unknown): void => {
789
+ if (currentSignal !== undefined) {
790
+ latchControlPlaneError(error, currentSignal);
791
+ } else {
792
+ collectFailure(emissionFailures, error);
793
+ }
794
+ };
894
795
  const enqueue = (fn: () => Promise<void>): Promise<void> => {
895
796
  const queued = emissionQueue.add(fn);
896
797
  activeEmissionCalls.add(queued);
@@ -920,7 +821,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
920
821
  const failures = emissionFailures;
921
822
  emissionFailures = [];
922
823
  if (failures.length === 1) throw failures[0];
923
- throw new AggregateError(failures, 'discuss runtime emissions failed');
824
+ throw new AggregateError(failures, 'decide runtime emissions failed');
924
825
  };
925
826
  const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
926
827
  while (true) {
@@ -941,22 +842,92 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
941
842
  };
942
843
  const requirePorts = (): PlaybookPorts => {
943
844
  if (!ports) {
944
- throw new Error('discuss runtime: init(session) must be called first');
845
+ throw new Error('decide runtime: init(session) must be called first');
945
846
  }
946
847
  return ports;
947
848
  };
948
849
  const requireSessionIdentity = (): SessionIdentity => {
949
850
  if (!sessionIdentity) {
950
- throw new Error('discuss runtime: init(session) must be called first');
851
+ throw new Error('decide runtime: init(session) must be called first');
951
852
  }
952
853
  return sessionIdentity;
953
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
+ };
954
923
  const currentState = (): PlaybookState => {
955
924
  const live = actor;
956
925
  if (!live) {
957
- throw new Error('discuss runtime: actor is not initialized');
926
+ throw new Error('decide runtime: actor is not initialized');
958
927
  }
959
- return normalizePlaybookSnapshot(live.getSnapshot());
928
+ return normalizePlaybookSnapshot(live.getSnapshot(), {
929
+ pendingCall: nestedBridge.getPendingCall(),
930
+ });
960
931
  };
961
932
  const stateIdentity = (state: PlaybookState): { stateId?: string } => {
962
933
  return state.stateId === undefined ? {} : { stateId: state.stateId };
@@ -1062,7 +1033,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1062
1033
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
1063
1034
  };
1064
1035
  const queued = await judgeQueue.add(async () => {
1065
- // Keep the complete queue task pending until an active host promise
1036
+ // Keep the complete queue task pending until an active coder promise
1066
1037
  // settles. PQueue's signal option may reject add() while that task is
1067
1038
  // still running, which would let the turn drain race a late finish.
1068
1039
  signal.throwIfAborted();
@@ -1134,10 +1105,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1134
1105
  `resolved player "${playerId}" already has an in-flight call`,
1135
1106
  );
1136
1107
  }
1137
- inFlightPlayerIds.add(playerId);
1138
1108
  const prompt = composePlayerPrompt(input);
1139
- const resume: PlayerCallOptions['resume'] =
1140
- 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
+ }
1141
1117
  const callId = `player-${++playerCallSequence}`;
1142
1118
  const identity = {
1143
1119
  purpose: 'captain',
@@ -1159,6 +1135,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1159
1135
  },
1160
1136
  { turnId: currentTurnId, callId },
1161
1137
  );
1138
+ inFlightPlayerIds.add(playerId);
1162
1139
  try {
1163
1140
  await emitCallStarted(
1164
1141
  'player.call.started',
@@ -1175,13 +1152,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1175
1152
  requirePorts().callPlayer(playerId, prompt, signal, { resume }),
1176
1153
  );
1177
1154
  rawResult = await boundary;
1178
- // An XState sibling cancellation does not cancel an arbitrary host
1155
+ // An XState sibling cancellation does not cancel an arbitrary coder
1179
1156
  // promise. Re-check before a late resolution can mutate continuity or
1180
1157
  // masquerade as a successful boundary finish.
1181
1158
  signal.throwIfAborted();
1182
1159
  } catch (error) {
1183
1160
  // A rejected call produced no authoritative result, so the previous
1184
- // token remains untouched. This also covers a host promise that
1161
+ // token remains untouched. This also covers a coder promise that
1185
1162
  // resolves after its invocation signal was cancelled.
1186
1163
  latchControlPlaneError(error, signal);
1187
1164
  try {
@@ -1200,20 +1177,29 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1200
1177
  try {
1201
1178
  await emitFailure(error);
1202
1179
  } catch {
1203
- // The malformed host result remains authoritative.
1180
+ // The malformed coder result remains authoritative.
1204
1181
  }
1205
1182
  throw error;
1206
1183
  }
1207
1184
 
1208
1185
  // The resolved result is authoritative even on aborted/error status.
1209
1186
  // Update continuation state before interpreting that status.
1210
- if (
1211
- typeof result.resumeToken === 'string' &&
1212
- result.resumeToken.trim().length > 0
1213
- ) {
1214
- playerResumeTokens.set(playerId, result.resumeToken);
1215
- } else {
1216
- 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;
1217
1203
  }
1218
1204
 
1219
1205
  // Keep this finish outside the boundary catch. A trace sink can record
@@ -1263,7 +1249,19 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1263
1249
  }
1264
1250
  combined.throwIfAborted();
1265
1251
 
1266
- const { playerId, result } = await callPlayer(input, combined);
1252
+ let { playerId, result } = await callPlayer(input, combined);
1253
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1254
+ // DR-028: an `ok` result whose finalText is missing, empty, or
1255
+ // whitespace-only earns exactly one corrective re-ask — the same
1256
+ // composed call repeated, traced by runPlayerCall as its own
1257
+ // player-call pair, with the resume selection re-read from the
1258
+ // token map the first result left (PBRT-38). An abort that lands
1259
+ // between the two calls ends the turn without the re-ask (aborts
1260
+ // are never retried), and a rejecting finish emission rejects
1261
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1262
+ combined.throwIfAborted();
1263
+ ({ playerId, result } = await callPlayer(input, combined));
1264
+ }
1267
1265
  if (result.status !== 'ok') {
1268
1266
  throw new Error(
1269
1267
  `player "${playerId}" returned status "${result.status}"${
@@ -1271,7 +1269,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1271
1269
  }`,
1272
1270
  );
1273
1271
  }
1274
- if (result.finalText === undefined) {
1272
+ const finalText = result.finalText ?? '';
1273
+ if (isEmptyFinalText(finalText)) {
1275
1274
  throw new Error(
1276
1275
  `player "${playerId}" returned status "ok" with no finalText`,
1277
1276
  );
@@ -1279,7 +1278,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1279
1278
  combined.throwIfAborted();
1280
1279
 
1281
1280
  try {
1282
- const prompt = buildAdjudicatorPrompt(input, result.finalText);
1281
+ const prompt = buildAdjudicatorPrompt(input, finalText);
1283
1282
  return parseAdjudication(
1284
1283
  await callJudge(
1285
1284
  prompt,
@@ -1288,6 +1287,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1288
1287
  input.stateId,
1289
1288
  ),
1290
1289
  input,
1290
+ finalText,
1291
1291
  );
1292
1292
  } catch (error) {
1293
1293
  latchControlPlaneError(error, combined);
@@ -1296,89 +1296,162 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1296
1296
  },
1297
1297
  );
1298
1298
 
1299
- 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
+ });
1300
1359
 
1301
1360
  const inspect = (event: InspectionEvent): void => {
1302
1361
  if (event.type !== '@xstate.snapshot') return;
1303
1362
  if (actor === undefined || event.actorRef !== actor) return;
1304
1363
  if (suppressInspectionEmissions) return;
1305
- const snapshot = event.snapshot as SnapshotFrom<typeof discussMachine>;
1306
- const state = normalizePlaybookSnapshot(snapshot);
1307
- const prior = previousState;
1308
- previousState = state;
1309
-
1310
- const runtimePorts = ports;
1311
- if (!runtimePorts) return;
1312
- const context = snapshot.context as Record<string, unknown>;
1313
- const fsmPayload = telemetryPayload(prior, state, event.event, context);
1314
- const describedFsmPayload = snapshotJsonValue(
1315
- fsmPayload,
1316
- 'described FSM telemetry',
1317
- );
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
+ );
1318
1374
 
1319
- void enqueueTracedEmission(
1320
- 'fsm.transition',
1321
- fsmPayload,
1322
- { turnId: currentTurnId },
1323
- (emissionPorts) =>
1324
- emissionPorts.emitTelemetry({
1325
- topic: TELEMETRY_TOPIC,
1326
- payload: describedFsmPayload,
1327
- }),
1328
- ).catch(() => undefined);
1329
-
1330
- const priorIds = new Set(prior?.activeStateIds ?? []);
1331
- const pendingQuestions = pendingQuestionsFromContext(context);
1332
- const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1333
- STATUS_STATE_IDS.has(stateId),
1334
- );
1335
- const scheduleStatus = (
1336
- message: string,
1337
- stateId: string,
1338
- data?: JsonValue,
1339
- ): void => {
1340
- const tracePayload = {
1341
- ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1342
- message,
1343
- state,
1344
- ...(data !== undefined ? { data } : {}),
1345
- };
1346
- assertJsonSafe(tracePayload);
1347
1375
  void enqueueTracedEmission(
1348
- 'status.emitted',
1349
- tracePayload,
1376
+ 'fsm.transition',
1377
+ fsmPayload,
1350
1378
  { turnId: currentTurnId },
1351
- (emissionPorts) => emissionPorts.emitStatus(message, data),
1379
+ (emissionPorts) =>
1380
+ emissionPorts.emitTelemetry({
1381
+ topic: TELEMETRY_TOPIC,
1382
+ payload: describedFsmPayload,
1383
+ }),
1352
1384
  ).catch(() => undefined);
1353
- };
1354
1385
 
1355
- for (const activeStateId of state.activeStateIds) {
1356
- if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
1357
- continue;
1358
- }
1359
- if (WAIT_STATE_IDS.has(activeStateId)) {
1360
- const pending = questionForWaitState(activeStateId, pendingQuestions);
1361
- if (pending) {
1362
- scheduleStatus(
1363
- `${pending.player} asks: ${pending.question}`,
1364
- activeStateId,
1365
- );
1366
- scheduleStatus(
1367
- `◆ 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(
1368
1421
  activeStateId,
1422
+ pendingQuestions,
1369
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;
1370
1435
  }
1371
- 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
+ );
1372
1452
  }
1373
- const lastError =
1374
- activeStateId === 'failed'
1375
- ? normalizeErrorCompact(context.lastError)
1376
- : undefined;
1377
- scheduleStatus(
1378
- STATE_DESCRIPTIONS[activeStateId] ?? activeStateId,
1379
- activeStateId,
1380
- lastError === undefined ? undefined : { lastError },
1381
- );
1453
+ } catch (error) {
1454
+ latchInspectionError(error);
1382
1455
  }
1383
1456
  };
1384
1457
 
@@ -1392,34 +1465,60 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1392
1465
  ? {}
1393
1466
  : {
1394
1467
  snapshot: machineSnapshot as unknown as SnapshotFrom<
1395
- typeof discussMachine
1468
+ typeof decideMachine
1396
1469
  >,
1397
1470
  }),
1398
1471
  inspect,
1399
1472
  });
1400
1473
  };
1401
1474
 
1475
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
1476
+ // still-running actor fires one more `@xstate.snapshot` for the *unchanged*
1477
+ // state value with `status: 'stopped'`; `inspect` cannot tell that disposal
1478
+ // artifact from a state entry, so unsuppressed it re-emits the parked
1479
+ // state's telemetry and a phantom self-loop transition. Suppression is a
1480
+ // property of stopping, not a rule each caller must remember — every stop
1481
+ // goes through here so no later site can reintroduce the omission.
1482
+ const stopActor = (): void => {
1483
+ if (!actor) return;
1484
+ suppressInspectionEmissions = true;
1485
+ actor.stop();
1486
+ };
1487
+
1402
1488
  const startActor = (): void => {
1403
1489
  createRuntimeActor();
1490
+ // A fresh actor's emissions are real state entries again.
1491
+ suppressInspectionEmissions = false;
1404
1492
  actor?.start();
1405
1493
  };
1406
1494
 
1407
1495
  const driveToQuiescence = async (): Promise<void> => {
1408
1496
  const live = actor;
1409
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1410
- await waitForPlaybookQuiescence(live);
1497
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1498
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
1411
1499
  };
1412
1500
 
1413
1501
  const classify = async (
1414
1502
  text: string,
1415
1503
  signal: AbortSignal,
1416
- ): Promise<DiscussEvent | null> => {
1504
+ ): Promise<DecideEvent | { type: 'NO_ACTION' } | null> => {
1417
1505
  const live = actor;
1418
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1419
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof discussMachine>;
1420
- const context = snapshot.context as Record<string, unknown>;
1421
- 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
+ });
1422
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;
1423
1522
  const prompt = buildClassifierPrompt(text, {
1424
1523
  state,
1425
1524
  pendingQuestions,
@@ -1432,16 +1531,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1432
1531
  );
1433
1532
  return parseClassification(
1434
1533
  raw,
1534
+ text,
1435
1535
  pendingQuestions.map(({ questionId }) => questionId),
1436
1536
  );
1437
1537
  };
1438
1538
 
1439
1539
  const resultForSnapshot = (signal?: AbortSignal): PlaybookRunResult => {
1440
1540
  const live = actor;
1441
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1442
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof discussMachine>;
1443
- const state = normalizePlaybookSnapshot(snapshot);
1444
- 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>;
1445
1546
  if (signal?.aborted) {
1446
1547
  return {
1447
1548
  outcome: 'aborted',
@@ -1468,7 +1569,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1468
1569
  if (snapshot.status === 'error') {
1469
1570
  throw (
1470
1571
  (snapshot as { error?: unknown }).error ??
1471
- new Error('discuss runtime actor entered error status')
1572
+ new Error('decide runtime actor entered error status')
1472
1573
  );
1473
1574
  }
1474
1575
  if (state.activeStateIds.includes('failed')) {
@@ -1479,6 +1580,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1479
1580
  ...(error === undefined ? {} : { error }),
1480
1581
  };
1481
1582
  }
1583
+ if (pendingCall) {
1584
+ return { outcome: 'suspended', state, pendingCall };
1585
+ }
1482
1586
  return { outcome: 'quiescent', state };
1483
1587
  };
1484
1588
 
@@ -1499,9 +1603,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1499
1603
  // A state that cannot even normalize has no disposal descriptor.
1500
1604
  }
1501
1605
  }
1502
- suppressInspectionEmissions = true;
1503
1606
  try {
1504
- actor?.stop();
1607
+ stopActor();
1505
1608
  } catch {
1506
1609
  // Preserve the original startup failure.
1507
1610
  }
@@ -1542,6 +1645,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1542
1645
  turnSequence = 0;
1543
1646
  judgeCallSequence = 0;
1544
1647
  playerCallSequence = 0;
1648
+ playbookCallSequence = 0;
1649
+ playbookCallTurnIds.clear();
1545
1650
  lifecycleStarted = false;
1546
1651
  };
1547
1652
 
@@ -1554,7 +1659,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1554
1659
  disposalPromise !== undefined
1555
1660
  ) {
1556
1661
  throw new Error(
1557
- 'discuss runtime: init(session) may only be called once',
1662
+ 'decide runtime: init(session) may only be called once',
1558
1663
  );
1559
1664
  }
1560
1665
  const identity = snapshotPlaybookSession(session);
@@ -1588,8 +1693,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1588
1693
  // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1589
1694
  // Defined only at a safe capture point — initialized, not disposing
1590
1695
  // or disposed, no active public boundary, and the actor quiescent
1591
- // with status `active`. DISCUSS never opens nested playbook calls,
1592
- // so no pending-call guard applies.
1696
+ // with status `active` and no pending nested REVIEW call.
1593
1697
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1594
1698
  if (
1595
1699
  !actor ||
@@ -1602,25 +1706,26 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1602
1706
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1603
1707
  return undefined;
1604
1708
  }
1709
+ if (nestedBridge.getPendingCall()) return undefined;
1605
1710
  const state = currentState();
1606
1711
  if (state.status !== 'active' || !state.quiescent) return undefined;
1607
1712
  const machine = detachPersistedMachineSnapshot(
1608
1713
  actor.getPersistedSnapshot(),
1609
1714
  );
1610
1715
  const context = (
1611
- actor.getSnapshot() as SnapshotFrom<typeof discussMachine>
1612
- ).context as Record<string, unknown>;
1716
+ actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1717
+ ).context as unknown as Record<string, unknown>;
1613
1718
  return {
1614
1719
  schemaVersion: 1,
1615
1720
  playbookId: sessionIdentity.playbookId,
1616
1721
  machine,
1617
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1722
+ playerResumeTokens: snapshotPlayerResumeTokens(),
1618
1723
  sequences: {
1619
1724
  trace: traceSequence,
1620
1725
  turn: turnSequence,
1621
1726
  judgeCall: judgeCallSequence,
1622
1727
  playerCall: playerCallSequence,
1623
- playbookCall: 0,
1728
+ playbookCall: playbookCallSequence,
1624
1729
  },
1625
1730
  state,
1626
1731
  pendingBossQuestions: pendingQuestionsFromContext(context).map(
@@ -1650,7 +1755,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1650
1755
  disposalPromise !== undefined
1651
1756
  ) {
1652
1757
  throw new Error(
1653
- 'discuss runtime: restore(session, snapshot) may only be called once',
1758
+ 'decide runtime: restore(session, snapshot) may only be called once',
1654
1759
  );
1655
1760
  }
1656
1761
  const identity = snapshotPlaybookSession(session);
@@ -1666,32 +1771,45 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1666
1771
  lifecycleStarted = true;
1667
1772
  ports = identity.ports;
1668
1773
  sessionIdentity = identity;
1774
+ let priorExternalPlayerTokens:
1775
+ | Readonly<Record<string, string>>
1776
+ | undefined;
1669
1777
  try {
1670
1778
  traceSequence = boundSnapshot.sequences.trace;
1671
1779
  turnSequence = boundSnapshot.sequences.turn;
1672
1780
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1673
1781
  playerCallSequence = boundSnapshot.sequences.playerCall;
1674
- playerResumeTokens.clear();
1675
- for (const [playerId, token] of Object.entries(
1676
- boundSnapshot.playerResumeTokens,
1677
- )) {
1678
- playerResumeTokens.set(playerId, token);
1782
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1783
+ if (identity.playerSessions) {
1784
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1679
1785
  }
1786
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1680
1787
  suppressInspectionEmissions = true;
1681
1788
  createRuntimeActor(boundSnapshot.machine);
1682
1789
  actor?.start();
1683
1790
  const restoredState = currentState();
1684
1791
  if (restoredState.status !== 'active') {
1685
1792
  throw new Error(
1686
- `discuss runtime: restored actor status is ${restoredState.status}, expected active`,
1793
+ `decide runtime: restored actor status is ${restoredState.status}, expected active`,
1687
1794
  );
1688
1795
  }
1689
1796
  suppressInspectionEmissions = false;
1690
1797
  previousState = restoredState;
1691
1798
  await flush();
1692
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
+ }
1693
1811
  await cleanupFailedStart({ emitDisposal: false });
1694
- throw error;
1812
+ throw failure;
1695
1813
  } finally {
1696
1814
  finishInitialization();
1697
1815
  if (initInFlight === initialization) initInFlight = undefined;
@@ -1703,16 +1821,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1703
1821
  signal: AbortSignal;
1704
1822
  }): Promise<PlaybookRunResult> {
1705
1823
  if (disposalPromise !== undefined) {
1706
- throw new Error('discuss runtime: runtime is disposing or disposed');
1824
+ throw new Error('decide runtime: runtime is disposing or disposed');
1707
1825
  }
1708
1826
  requirePorts();
1709
1827
  if (!actor) {
1710
1828
  throw new Error(
1711
- 'discuss runtime: init(session) must be called before handleBossInput',
1829
+ 'decide runtime: init(session) must be called before handleBossInput',
1712
1830
  );
1713
1831
  }
1714
- if (currentTurnId !== undefined) {
1715
- 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');
1716
1834
  }
1717
1835
 
1718
1836
  const turnId = ++turnSequence;
@@ -1733,10 +1851,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1733
1851
  const state = currentState();
1734
1852
  await emitBoundaryStatus('No playbook action classified.', state);
1735
1853
  result = { outcome: 'no-action', state };
1854
+ } else if (event.type === 'NO_ACTION') {
1855
+ result = { outcome: 'no-action', state: currentState() };
1736
1856
  } else {
1737
1857
  await emitBoundaryStatus(event.type, currentState());
1738
1858
  if (actor.getSnapshot().status === 'done') {
1739
- actor.stop();
1859
+ stopActor();
1740
1860
  startActor();
1741
1861
  }
1742
1862
 
@@ -1817,36 +1937,77 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1817
1937
 
1818
1938
  if (failures.length === 1) throw failures[0];
1819
1939
  if (failures.length > 1) {
1820
- throw new AggregateError(failures, 'discuss runtime turn failed');
1940
+ throw new AggregateError(failures, 'decide runtime turn failed');
1821
1941
  }
1822
1942
  return result;
1823
1943
  },
1824
1944
 
1825
1945
  async resumePlaybookCall({
1826
1946
  callId,
1947
+ result: childResult,
1948
+ signal,
1827
1949
  }: {
1828
1950
  callId: string;
1829
1951
  result: PlaybookCallResult;
1830
1952
  signal: AbortSignal;
1831
1953
  }): Promise<PlaybookRunResult> {
1832
1954
  if (disposalPromise !== undefined) {
1833
- throw new Error('discuss runtime: runtime is disposing or disposed');
1955
+ throw new Error('decide runtime: runtime is disposing or disposed');
1834
1956
  }
1835
1957
  requirePorts();
1836
1958
  if (!actor) {
1837
1959
  throw new Error(
1838
- 'discuss runtime: init(session) must be called before resumePlaybookCall',
1960
+ 'decide runtime: init(session) must be called before resumePlaybookCall',
1839
1961
  );
1840
1962
  }
1841
- 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;
1842
2003
  },
1843
2004
 
1844
2005
  dispose(): Promise<void> {
1845
2006
  if (disposalPromise !== undefined) return disposalPromise;
1846
- if (currentTurnId !== undefined) {
2007
+ if (currentSignal !== undefined) {
1847
2008
  return Promise.reject(
1848
2009
  new Error(
1849
- 'discuss runtime: cannot dispose while a runtime turn is active',
2010
+ 'decide runtime: cannot dispose while a runtime turn is active',
1850
2011
  ),
1851
2012
  );
1852
2013
  }
@@ -1859,8 +2020,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1859
2020
  }
1860
2021
  const finalState = currentState();
1861
2022
  const failures: unknown[] = [];
1862
- if (actor) {
1863
- actor.stop();
2023
+ stopActor();
2024
+ try {
2025
+ await nestedBridge.dispose();
2026
+ } catch (error) {
2027
+ collectFailure(failures, error);
1864
2028
  }
1865
2029
  try {
1866
2030
  await drainBoundaryCallsAndEmissions();
@@ -1881,6 +2045,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1881
2045
  collectFailure(failures, error);
1882
2046
  } finally {
1883
2047
  playerResumeTokens.clear();
2048
+ playbookCallTurnIds.clear();
1884
2049
  inFlightPlayerIds.clear();
1885
2050
  activeBoundaryCalls.clear();
1886
2051
  activeEmissionCalls.clear();
@@ -1897,7 +2062,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1897
2062
  }
1898
2063
  if (failures.length === 1) throw failures[0];
1899
2064
  if (failures.length > 1) {
1900
- throw new AggregateError(failures, 'discuss runtime disposal failed');
2065
+ throw new AggregateError(failures, 'decide runtime disposal failed');
1901
2066
  }
1902
2067
  })();
1903
2068
  return disposalPromise;
@@ -1919,10 +2084,10 @@ export const _internal = {
1919
2084
  normalizeErrorCompact,
1920
2085
  normalizeErrorFull,
1921
2086
  DEFAULT_PLAYER_BINDING,
1922
- ALIAS_RESOLUTION,
1923
2087
  STATE_DESCRIPTIONS,
1924
- CAPTAIN_STATES,
1925
- CAPTAIN_STATE_IDS,
2088
+ PLAYER_STATES,
2089
+ PLAYER_STATE_IDS,
2090
+ VERBATIM_PAYLOAD_FIELDS,
1926
2091
  BOSS_INTERRUPT_TARGETS,
1927
2092
  CONTINUATION_PREAMBLE,
1928
2093
  TELEMETRY_TOPIC,