@sublang/playbook 5.0.0 → 7.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 (72) hide show
  1. package/README.md +22 -14
  2. package/docs/cli.md +138 -90
  3. package/docs/configuration.md +113 -29
  4. package/docs/embedding.md +24 -16
  5. package/package.json +42 -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/bin/launch-config.js +938 -0
  10. package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
  11. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  12. package/reference/sdlc/code.playbook/bin/run.js +1171 -983
  13. package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  18. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  19. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  20. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  22. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  23. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  25. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  26. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
  28. package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
  31. package/reference/sdlc/decide.md +54 -0
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  35. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  37. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
  38. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  40. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  42. package/reference/sdlc/review.md +81 -0
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  45. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  46. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  48. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  51. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  52. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  53. package/slc/gears2fsm.md +13 -4
  54. package/slc/link.md +83 -14
  55. package/slc/text2gears.md +22 -2
  56. package/src/runtime.d.ts +21 -2
  57. package/src/runtime.ts +38 -6
  58. package/src/xstate-playbook-runtime.d.ts +9 -2
  59. package/src/xstate-playbook-runtime.js +319 -35
  60. package/src/xstate-playbook-runtime.ts +412 -41
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +384 -57
  63. package/src/xstate-runtime.ts +542 -71
  64. package/reference/sdlc/discuss.md +0 -93
  65. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  68. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  69. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  70. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  71. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  72. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -1,205 +1,97 @@
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
  import PQueue from 'p-queue';
23
19
  import { createActor, fromPromise } from 'xstate';
24
- import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
25
- import discussMachine from './discuss.fsm.js';
20
+ import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
21
+ import decideMachine from './decide.fsm.js';
26
22
  const DEFAULT_PLAYER_BINDING = {
27
- Host: 'host',
28
- Participant: 'participant',
29
- Committer: 'committer',
23
+ Coder: 'coder',
24
+ Reviewer: 'reviewer',
30
25
  };
31
- const ALIAS_RESOLUTION = {
32
- 'DISCUSS-14': 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
33
- 'DISCUSS-15': 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
34
- };
35
- function snapshotDiscussRuntimeOptions(value) {
36
- const captured = snapshotJsonValue(value, 'DISCUSS runtime options');
26
+ function snapshotDecideRuntimeOptions(value) {
27
+ const captured = snapshotJsonValue(value, 'DECIDE runtime options');
37
28
  if (!isPlainObject(captured)) {
38
- throw new TypeError('DISCUSS runtime options must be an object');
29
+ throw new TypeError('DECIDE runtime options must be an object');
39
30
  }
40
- const allowed = new Set([
41
- 'host',
42
- 'participant',
43
- 'committer',
44
- 'playerBinding',
45
- ]);
31
+ const allowed = new Set(['coderLlm', 'playerBinding']);
46
32
  for (const key of Object.keys(captured)) {
47
33
  if (!allowed.has(key)) {
48
- throw new TypeError(`DISCUSS runtime options.${key} is not declared`);
34
+ throw new TypeError(`DECIDE runtime options.${key} is not declared`);
49
35
  }
50
36
  }
51
- for (const key of ['host', 'participant', 'committer']) {
52
- if (key in captured && typeof captured[key] !== 'string') {
53
- throw new TypeError(`DISCUSS runtime options.${key} must be a string`);
54
- }
37
+ if (typeof captured.coderLlm !== 'string' ||
38
+ captured.coderLlm.trim().length === 0) {
39
+ throw new TypeError('DECIDE runtime options.coderLlm must be a non-empty string');
55
40
  }
56
41
  if ('playerBinding' in captured) {
57
42
  const playerBinding = captured.playerBinding;
58
43
  if (!isPlainObject(playerBinding)) {
59
- throw new TypeError('DISCUSS runtime options.playerBinding must be an object');
44
+ throw new TypeError('DECIDE runtime options.playerBinding must be an object');
60
45
  }
61
- const playerNames = new Set([
62
- 'Host',
63
- 'Participant',
64
- 'Committer',
65
- ]);
46
+ const playerNames = new Set(['Coder', 'Reviewer']);
66
47
  for (const [player, playerId] of Object.entries(playerBinding)) {
67
48
  if (!playerNames.has(player)) {
68
- throw new TypeError(`DISCUSS runtime options.playerBinding.${player} is not declared`);
49
+ throw new TypeError(`DECIDE runtime options.playerBinding.${player} is not declared`);
69
50
  }
70
51
  if (typeof playerId !== 'string' || playerId.trim().length === 0) {
71
- throw new TypeError(`DISCUSS runtime options.playerBinding.${player} must be a non-empty string`);
52
+ throw new TypeError(`DECIDE runtime options.playerBinding.${player} must be a non-empty string`);
72
53
  }
73
54
  }
74
55
  }
75
56
  return captured;
76
57
  }
77
58
  const STATE_DESCRIPTIONS = {
78
- ready: 'Idle hub awaiting a Boss discussion or review directive.',
79
- askHostInitial: 'Host proposes whether the Boss topic should become spec items or DRs.',
80
- askParticipantInitial: 'Participant independently proposes whether the Boss topic should become spec items or DRs.',
81
- hostInitialRound: 'Host reconciles the Participant proposal during initial discussion.',
82
- participantInitialRound: 'Participant reconciles the Host proposal during initial discussion.',
83
- hostWritesAgreement: 'Host writes the agreed spec items or DRs and updates the spec map.',
84
- commitInitialChanges: 'Committer commits the changes produced at the end of initial discussion.',
85
- reviewSpecInitialCommit: 'Participant reviews newly committed spec-item changes.',
86
- reviewSpecHostChanges: 'Participant reviews Host changes to spec items after findings.',
87
- reviewDrInitialCommit: 'Participant reviews newly committed decision-record changes.',
88
- reviewDrHostChanges: 'Participant reviews Host changes to decision records after findings.',
89
- reviewMixedInitialCommit: 'Participant reviews newly committed mixed spec-item and DR changes.',
90
- reviewMixedHostChanges: 'Participant reviews Host changes to mixed spec items and DRs after findings.',
91
- hostAddressesFindings: 'Host accepts or challenges review findings and stages repo changes.',
92
- participantAddressesRebuttals: 'Participant accepts or challenges Host rebuttals.',
93
- commitReviewedChanges: 'Committer commits reviewed changes once Participant raises no findings.',
94
- awaitBossReply: 'Waiting for Boss to answer a player question.',
95
- failed: 'The discussion workflow failed and is waiting for Boss recovery.',
96
- done: 'The discussion workflow completed with a reviewed commit.',
59
+ ready: 'Waiting for a topic to decide.',
60
+ askCoderProposal: 'Coder independently proposes a spec design.',
61
+ askReviewerProposal: 'Reviewer independently proposes a spec design.',
62
+ waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
63
+ waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
64
+ commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
65
+ awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
66
+ reviewCommit: 'REVIEW examines the committed proposal.',
67
+ failed: 'DECIDE failed and is waiting for a new topic.',
68
+ reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
69
+ done: 'DECIDE completed with an approved commit.',
97
70
  };
98
- const CAPTAIN_STATES = [
99
- { stateId: 'askHostInitial', player: 'Host', sourceItem: 'DISCUSS-1' },
100
- {
101
- stateId: 'askParticipantInitial',
102
- player: 'Participant',
103
- sourceItem: 'DISCUSS-2',
104
- },
105
- { stateId: 'hostInitialRound', player: 'Host', sourceItem: 'DISCUSS-3' },
106
- {
107
- stateId: 'participantInitialRound',
108
- player: 'Participant',
109
- sourceItem: 'DISCUSS-4',
110
- },
111
- { stateId: 'hostWritesAgreement', player: 'Host', sourceItem: 'DISCUSS-5' },
112
- {
113
- stateId: 'commitInitialChanges',
114
- player: 'Committer',
115
- sourceItem: 'DISCUSS-14',
116
- },
117
- {
118
- stateId: 'reviewSpecInitialCommit',
119
- player: 'Participant',
120
- sourceItem: 'DISCUSS-6',
121
- },
122
- {
123
- stateId: 'reviewSpecHostChanges',
124
- player: 'Participant',
125
- sourceItem: 'DISCUSS-7',
126
- },
127
- {
128
- stateId: 'reviewDrInitialCommit',
129
- player: 'Participant',
130
- sourceItem: 'DISCUSS-8',
131
- },
132
- {
133
- stateId: 'reviewDrHostChanges',
134
- player: 'Participant',
135
- sourceItem: 'DISCUSS-9',
136
- },
137
- {
138
- stateId: 'reviewMixedInitialCommit',
139
- player: 'Participant',
140
- sourceItem: 'DISCUSS-10',
141
- },
142
- {
143
- stateId: 'reviewMixedHostChanges',
144
- player: 'Participant',
145
- sourceItem: 'DISCUSS-11',
146
- },
147
- {
148
- stateId: 'hostAddressesFindings',
149
- player: 'Host',
150
- sourceItem: 'DISCUSS-12',
151
- },
152
- {
153
- stateId: 'participantAddressesRebuttals',
154
- player: 'Participant',
155
- sourceItem: 'DISCUSS-13',
156
- },
71
+ const PLAYER_STATES = [
72
+ { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
157
73
  {
158
- stateId: 'commitReviewedChanges',
159
- player: 'Committer',
160
- sourceItem: 'DISCUSS-15',
74
+ stateId: 'askReviewerProposal',
75
+ player: 'Reviewer',
76
+ sourceItem: 'DECIDE-2',
161
77
  },
78
+ { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
162
79
  ];
163
- const CAPTAIN_STATE_IDS = new Set(CAPTAIN_STATES.map((state) => state.stateId));
164
- const BOSS_INTERRUPT_TARGETS = [
165
- 'ready',
166
- 'initialProposalRound',
167
- 'reconciliationRound',
168
- 'hostWritesAgreement',
169
- 'commitInitialChanges',
170
- 'reviewSpecInitialCommit',
171
- 'reviewSpecHostChanges',
172
- 'reviewDrInitialCommit',
173
- 'reviewDrHostChanges',
174
- 'reviewMixedInitialCommit',
175
- 'reviewMixedHostChanges',
176
- 'hostAddressesFindings',
177
- 'participantAddressesRebuttals',
178
- 'commitReviewedChanges',
179
- 'failed',
180
- ];
80
+ const PLAYER_STATE_IDS = new Set(PLAYER_STATES.map((state) => state.stateId));
81
+ const BOSS_INTERRUPT_TARGETS = ['independentProposals'];
181
82
  const BOSS_INTERRUPT_TARGET_IDS = new Set(BOSS_INTERRUPT_TARGETS);
182
- const REVIEW_SCOPES = new Set([
183
- 'specItems',
184
- 'decisionRecords',
185
- 'mixed',
186
- ]);
187
83
  const TELEMETRY_TOPIC = 'playbook.fsm.state';
188
84
  const TRACE_TOPIC = 'playbook.trace';
189
85
  const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
190
86
  const PLACEHOLDER_FIELDS = [
191
- ['<topic>', 'topic'],
192
- ['<participant-proposal>', 'participantProposal'],
193
- ['<host-previous-proposal>', 'hostProposal'],
194
- ['<host-proposal>', 'hostProposal'],
195
- ['<participant-previous-proposal>', 'participantProposal'],
196
- ['<agreement>', 'agreement'],
197
- ['<changes>', 'latestChanges'],
198
- ['<review-items>', 'reviewItems'],
199
- ['<rebuttals>', 'rebuttals'],
200
- ['<host-llm>', 'hostLlm'],
201
- ['<participant-llm>', 'participantLlm'],
87
+ ['<caller-topic>', 'callerTopic'],
88
+ ['<coder-llm>', 'coderLlm'],
202
89
  ];
90
+ const VERBATIM_PAYLOAD_FIELDS = new Set([
91
+ 'coderProposal',
92
+ 'reviewerProposal',
93
+ 'coderOutput',
94
+ ]);
203
95
  function composePlayerPrompt(input) {
204
96
  const blocks = [];
205
97
  if (input.pendingBossQuestion && input.bossReply !== undefined) {
@@ -213,35 +105,38 @@ function composePlayerPrompt(input) {
213
105
  input.bossReply,
214
106
  ].join('\n'));
215
107
  }
216
- let body = input.prompt;
108
+ const replacements = new Map();
217
109
  for (const [placeholder, field] of PLACEHOLDER_FIELDS) {
218
110
  const value = input[field];
219
- if (typeof value === 'string') {
220
- body = body.replaceAll(placeholder, value);
221
- }
111
+ if (typeof value === 'string')
112
+ replacements.set(placeholder, value);
222
113
  }
114
+ const body = input.prompt.replace(/<caller-topic>|<coder-llm>/g, (placeholder, offset, source) => {
115
+ const value = replacements.get(placeholder);
116
+ if (value === undefined)
117
+ return placeholder;
118
+ const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
119
+ return source.slice(lineStart, offset) === '> '
120
+ ? value.replaceAll('\n', '\n> ')
121
+ : value;
122
+ });
223
123
  blocks.push(body);
224
124
  return blocks.join('\n\n');
225
125
  }
226
126
  function resolvePlayerId(input, binding) {
227
127
  switch (input.player) {
228
- case 'Host':
229
- return binding.Host;
230
- case 'Participant':
231
- return binding.Participant;
232
- case 'Committer':
233
- return input.committerPlayer ?? binding.Host;
128
+ case 'Coder':
129
+ return binding.Coder;
130
+ case 'Reviewer':
131
+ return binding.Reviewer;
234
132
  default: {
235
133
  const exhaustive = input.player;
236
134
  throw new Error(`unknown player ${String(exhaustive)}`);
237
135
  }
238
136
  }
239
137
  }
240
- // A `result` description names required payload fields in an
241
- // "Output shall include ..." sentence. One sentence can name several
242
- // fields — DISCUSS-5's wroteChanges names both `latestChanges` and
243
- // `reviewScope` — so every backticked `field:` token in the sentence
244
- // span is required, not just the one adjacent to the phrase.
138
+ // A `result` description names required payload fields in its
139
+ // "Output shall include ..." sentence.
245
140
  function requiredFieldsFor(description) {
246
141
  const fields = [];
247
142
  const sentence = /Output shall include([^.]*)/g;
@@ -308,6 +203,22 @@ function parseJudgeJson(raw) {
308
203
  function isPlainObject(value) {
309
204
  return value !== null && typeof value === 'object' && !Array.isArray(value);
310
205
  }
206
+ function sortJson(value) {
207
+ if (Array.isArray(value))
208
+ return value.map((entry) => sortJson(entry));
209
+ if (value !== null && typeof value === 'object') {
210
+ const record = value;
211
+ const sorted = {};
212
+ for (const key of Object.keys(record).sort()) {
213
+ sorted[key] = sortJson(record[key]);
214
+ }
215
+ return sorted;
216
+ }
217
+ return value;
218
+ }
219
+ function stableJson(value, path) {
220
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
221
+ }
311
222
  function stripCodeFence(text) {
312
223
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
313
224
  return fence ? fence[1].trim() : text;
@@ -364,7 +275,7 @@ function dropTrailingComma(value) {
364
275
  }
365
276
  function buildClassifierPrompt(text, ctx) {
366
277
  const lines = [];
367
- lines.push('You are the Boss-input classifier for the discuss playbook.');
278
+ lines.push('You are the Boss-input classifier for the decide playbook.');
368
279
  lines.push('Classify the Boss message into exactly one FSM event, or into no event.');
369
280
  lines.push('');
370
281
  lines.push(`Current FSM state: ${JSON.stringify(ctx.state.value)}`);
@@ -372,84 +283,66 @@ function buildClassifierPrompt(text, ctx) {
372
283
  if (ctx.pendingQuestions.length > 0) {
373
284
  lines.push('Pending Boss questions:');
374
285
  for (const pending of ctx.pendingQuestions) {
375
- lines.push(`- ${pending.questionId} (${pending.player}, ${pending.sourceItem}): ${pending.question}`);
286
+ lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
376
287
  }
377
288
  lines.push('If the Boss message answers a pending question, classify it as BOSS_REPLY; if it is a fresh directive, classify it accordingly.');
378
289
  }
379
290
  lines.push('');
380
291
  lines.push('Events and payload contracts:');
381
- lines.push('- START_DISCUSSION: required topic string; optional hostLlm, participantLlm strings (identities normally come from run options).');
382
- lines.push('- START_REVIEW: required latestChanges string, required reviewScope string ("specItems" | "decisionRecords" | "mixed"), optional rebuttals string.');
383
- lines.push(`- BOSS_INTERRUPT: required targetId string, one of ${BOSS_INTERRUPT_TARGETS.join(', ')}.`);
384
- lines.push('- BOSS_REPLY: required answer string and questionId when several questions are pending; valid only while at least one Boss question is pending.');
292
+ lines.push(`- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`);
293
+ lines.push('- 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.');
294
+ lines.push('- NO_ACTION: no fields.');
385
295
  lines.push('');
386
296
  lines.push('Boss message:');
387
297
  lines.push(text);
388
298
  lines.push('');
389
- lines.push('Reply with a single JSON object: { "event": "<EVENT_TYPE or null>", ...payload fields }.');
390
- lines.push('Use null when no FSM action applies.');
299
+ lines.push('Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.');
300
+ lines.push('Use NO_ACTION when no FSM action applies.');
391
301
  return lines.join('\n');
392
302
  }
393
- function parseClassification(raw, pendingQuestionIds = []) {
303
+ function parseClassification(raw, text, pendingQuestionIds = []) {
394
304
  const obj = extractJson(raw);
395
305
  if (!obj)
396
306
  return null;
397
- const eventType = obj.event ?? obj.type;
398
- if (eventType === 'START_DISCUSSION') {
399
- // hostLlm/participantLlm are optional on the FSM event: the identities
400
- // normally flow in via the machine input (run options).
401
- if (typeof obj.topic === 'string') {
402
- return {
403
- type: 'START_DISCUSSION',
404
- topic: obj.topic,
405
- ...(typeof obj.hostLlm === 'string' ? { hostLlm: obj.hostLlm } : {}),
406
- ...(typeof obj.participantLlm === 'string'
407
- ? { participantLlm: obj.participantLlm }
408
- : {}),
409
- };
410
- }
411
- return null;
412
- }
413
- if (eventType === 'START_REVIEW') {
414
- if (typeof obj.latestChanges === 'string' &&
415
- typeof obj.reviewScope === 'string' &&
416
- REVIEW_SCOPES.has(obj.reviewScope)) {
417
- return {
418
- type: 'START_REVIEW',
419
- latestChanges: obj.latestChanges,
420
- reviewScope: obj.reviewScope,
421
- ...(typeof obj.rebuttals === 'string'
422
- ? { rebuttals: obj.rebuttals }
423
- : {}),
424
- };
425
- }
426
- return null;
307
+ const exactKeys = (...keys) => {
308
+ const ownKeys = Reflect.ownKeys(obj);
309
+ return (ownKeys.length === keys.length &&
310
+ ownKeys.every((key) => typeof key === 'string' && keys.includes(key)));
311
+ };
312
+ const eventType = obj.type;
313
+ if (eventType === 'NO_ACTION') {
314
+ return exactKeys('type') ? { type: 'NO_ACTION' } : null;
427
315
  }
428
316
  if (eventType === 'BOSS_INTERRUPT') {
429
- if (typeof obj.targetId === 'string' &&
317
+ if (exactKeys('type', 'targetId') &&
318
+ typeof obj.targetId === 'string' &&
430
319
  BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)) {
431
- return { type: 'BOSS_INTERRUPT', targetId: obj.targetId };
320
+ return {
321
+ type: 'BOSS_INTERRUPT',
322
+ targetId: obj.targetId,
323
+ bossIntent: text,
324
+ };
432
325
  }
433
326
  return null;
434
327
  }
435
328
  if (eventType === 'BOSS_REPLY') {
436
- if (typeof obj.answer !== 'string' || pendingQuestionIds.length === 0) {
329
+ if (pendingQuestionIds.length === 0)
437
330
  return null;
438
- }
439
331
  if (typeof obj.questionId === 'string') {
440
- return pendingQuestionIds.includes(obj.questionId)
332
+ return exactKeys('type', 'questionId') &&
333
+ pendingQuestionIds.includes(obj.questionId)
441
334
  ? {
442
335
  type: 'BOSS_REPLY',
443
336
  questionId: obj.questionId,
444
- answer: obj.answer,
337
+ answer: text,
445
338
  }
446
339
  : null;
447
340
  }
448
- return pendingQuestionIds.length === 1
341
+ return exactKeys('type') && pendingQuestionIds.length === 1
449
342
  ? {
450
343
  type: 'BOSS_REPLY',
451
344
  questionId: pendingQuestionIds[0],
452
- answer: obj.answer,
345
+ answer: text,
453
346
  }
454
347
  : null;
455
348
  }
@@ -473,11 +366,22 @@ function buildAdjudicatorPrompt(input, playerOutput) {
473
366
  for (const [guard, description] of Object.entries(input.result)) {
474
367
  lines.push(`- ${guard}: ${description}`);
475
368
  }
369
+ const runtimeOwnedFields = new Set();
370
+ for (const description of Object.values(input.result)) {
371
+ for (const field of requiredFieldsFor(description)) {
372
+ if (VERBATIM_PAYLOAD_FIELDS.has(field))
373
+ runtimeOwnedFields.add(field);
374
+ }
375
+ }
376
+ if (runtimeOwnedFields.size > 0) {
377
+ lines.push('');
378
+ lines.push(`The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`);
379
+ }
476
380
  lines.push('');
477
381
  lines.push('Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.');
478
382
  return lines.join('\n');
479
383
  }
480
- function parseAdjudication(raw, input) {
384
+ function parseAdjudication(raw, input, finalText) {
481
385
  const obj = extractJson(raw);
482
386
  if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
483
387
  throw new Error('adjudicator returned empty or malformed JSON');
@@ -486,25 +390,26 @@ function parseAdjudication(raw, input) {
486
390
  if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
487
391
  throw new Error(`adjudicator returned undeclared guard "${guard}" for ${input.sourceItem}`);
488
392
  }
489
- // Per slc/link.md §Captain adjudication the judge answers
490
- // `{ guard, …payloadFields }` and the runtime validates the required
491
- // fields. Every payload field is carried through — dropping the
492
- // non-required ones would blind FSM fallbacks such as
493
- // `outputOf(event).reviewScope ?? context.reviewScope` on guards whose
494
- // description says "may include".
495
- const output = { ...obj, guard };
496
- for (const field of requiredFieldsFor(input.result[guard])) {
497
- const value = output[field];
393
+ const requiredFields = requiredFieldsFor(input.result[guard]);
394
+ const allowedFields = new Set(['guard', ...requiredFields]);
395
+ for (const key of Reflect.ownKeys(obj)) {
396
+ if (typeof key !== 'string' || !allowedFields.has(key)) {
397
+ throw new Error(`adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`);
398
+ }
399
+ }
400
+ const output = { guard };
401
+ for (const field of requiredFields) {
402
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
403
+ output[field] = finalText;
404
+ continue;
405
+ }
406
+ const value = obj[field];
498
407
  if (value === undefined ||
499
408
  value === null ||
500
409
  (typeof value === 'string' && value.trim() === '')) {
501
410
  throw new Error(`adjudicator response for guard "${guard}" missing required field "${field}"`);
502
411
  }
503
- }
504
- if ('reviewScope' in output &&
505
- typeof output.reviewScope === 'string' &&
506
- !REVIEW_SCOPES.has(output.reviewScope)) {
507
- throw new Error(`adjudicator returned invalid reviewScope "${output.reviewScope}"`);
412
+ output[field] = value;
508
413
  }
509
414
  return output;
510
415
  }
@@ -528,11 +433,7 @@ function isEmptyFinalText(finalText) {
528
433
  return finalText === undefined || finalText.trim().length === 0;
529
434
  }
530
435
  function isAbortFailure(error, signal) {
531
- if (!signal.aborted)
532
- return false;
533
- if (Object.is(error, signal.reason))
534
- return true;
535
- return normalizeErrorCompact(error)?.name === 'AbortError';
436
+ return signal.aborted && Object.is(error, signal.reason);
536
437
  }
537
438
  function pendingQuestionsFromContext(context) {
538
439
  const pending = context.pendingBossQuestions;
@@ -559,17 +460,15 @@ function pendingQuestionsFromContext(context) {
559
460
  return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
560
461
  }
561
462
  const WAIT_STATE_RESUME_IDS = {
562
- waitHostInitialReply: 'askHostInitial',
563
- waitParticipantInitialReply: 'askParticipantInitial',
564
- waitHostReconciliationReply: 'hostInitialRound',
565
- waitParticipantReconciliationReply: 'participantInitialRound',
463
+ waitCoderProposalReply: 'askCoderProposal',
464
+ waitReviewerProposalReply: 'askReviewerProposal',
566
465
  };
567
466
  const WAIT_STATE_IDS = new Set([
568
467
  ...Object.keys(WAIT_STATE_RESUME_IDS),
569
468
  'awaitBossReply',
570
469
  ]);
571
470
  const STATUS_STATE_IDS = new Set([
572
- ...CAPTAIN_STATE_IDS,
471
+ ...PLAYER_STATE_IDS,
573
472
  ...WAIT_STATE_IDS,
574
473
  'failed',
575
474
  ]);
@@ -582,38 +481,44 @@ function questionForWaitState(stateId, pendingQuestions) {
582
481
  ? pendingQuestions[0]
583
482
  : undefined;
584
483
  }
585
- function normalizedEventType(event) {
586
- if (event !== null &&
587
- typeof event === 'object' &&
588
- !Array.isArray(event) &&
589
- 'type' in event) {
590
- const type = event.type;
591
- return typeof type === 'string' ? type : String(type);
484
+ const TRANSITION_EVENT_FIELDS = [
485
+ 'callerTopic',
486
+ 'targetId',
487
+ 'bossIntent',
488
+ 'questionId',
489
+ 'answer',
490
+ ];
491
+ function normalizedTransitionEvent(event) {
492
+ if (event === null || typeof event !== 'object') {
493
+ return snapshotJsonValue(event ?? null, 'FSM event');
592
494
  }
593
- if (event === null ||
594
- typeof event === 'string' ||
595
- typeof event === 'boolean' ||
596
- (typeof event === 'number' && Number.isFinite(event))) {
597
- return event;
495
+ const source = event;
496
+ const descriptor = {};
497
+ if (typeof source.type === 'string')
498
+ descriptor.type = source.type;
499
+ for (const field of TRANSITION_EVENT_FIELDS) {
500
+ if (typeof source[field] === 'string') {
501
+ descriptor[field] = source[field];
502
+ }
598
503
  }
599
- return String(event);
504
+ if (source.output !== undefined) {
505
+ descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
506
+ }
507
+ if (source.error !== undefined) {
508
+ descriptor.error = snapshotJsonValue(normalizeError(source.error), 'FSM event error');
509
+ }
510
+ return snapshotJsonValue(descriptor, 'FSM event');
600
511
  }
601
512
  function telemetryPayload(previousState, state, event, context) {
602
- const eventError = event !== null &&
603
- typeof event === 'object' &&
604
- !Array.isArray(event) &&
605
- 'error' in event
606
- ? normalizeErrorFull(event.error)
607
- : undefined;
608
513
  const pendingBossQuestions = pendingQuestionsFromContext(context);
514
+ const prior = previousState ?? state;
609
515
  const payload = {
610
- from: previousState?.value ?? null,
516
+ from: prior.value,
611
517
  to: state.value,
612
- event: normalizedEventType(event),
613
- previousState: previousState ?? null,
518
+ event: normalizedTransitionEvent(event),
519
+ previousState: prior,
614
520
  state,
615
521
  ...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
616
- ...(eventError !== undefined ? { error: eventError } : {}),
617
522
  ...(state.activeStateIds.includes('failed')
618
523
  ? { lastError: normalizeErrorFull(context.lastError) ?? null }
619
524
  : {}),
@@ -622,15 +527,13 @@ function telemetryPayload(previousState, state, event, context) {
622
527
  return payload;
623
528
  }
624
529
  export const createPlaybookRuntime = (options) => {
625
- const boundOptions = snapshotDiscussRuntimeOptions(options);
530
+ const boundOptions = snapshotDecideRuntimeOptions(options);
626
531
  const binding = {
627
532
  ...DEFAULT_PLAYER_BINDING,
628
533
  ...(boundOptions.playerBinding ?? {}),
629
534
  };
630
535
  const fsmInput = {
631
- host: boundOptions.host,
632
- participant: boundOptions.participant,
633
- committer: boundOptions.committer,
536
+ coderLlm: boundOptions.coderLlm,
634
537
  };
635
538
  let ports;
636
539
  let sessionIdentity;
@@ -644,12 +547,15 @@ export const createPlaybookRuntime = (options) => {
644
547
  let turnSequence = 0;
645
548
  let judgeCallSequence = 0;
646
549
  let playerCallSequence = 0;
550
+ let playbookCallSequence = 0;
647
551
  let lifecycleStarted = false;
648
552
  let initInFlight;
649
553
  let disposed = false;
650
554
  let disposalPromise;
651
555
  let controlPlaneError;
556
+ let nestedBridge;
652
557
  const playerResumeTokens = new Map();
558
+ const playbookCallTurnIds = new Map();
653
559
  const inFlightPlayerIds = new Set();
654
560
  const activeBoundaryCalls = new Set();
655
561
  const activeEmissionCalls = new Set();
@@ -669,6 +575,14 @@ export const createPlaybookRuntime = (options) => {
669
575
  if (!isAbortFailure(error, signal))
670
576
  controlPlaneError ??= error;
671
577
  };
578
+ const latchInspectionError = (error) => {
579
+ if (currentSignal !== undefined) {
580
+ latchControlPlaneError(error, currentSignal);
581
+ }
582
+ else {
583
+ collectFailure(emissionFailures, error);
584
+ }
585
+ };
672
586
  const enqueue = (fn) => {
673
587
  const queued = emissionQueue.add(fn);
674
588
  activeEmissionCalls.add(queued);
@@ -696,7 +610,7 @@ export const createPlaybookRuntime = (options) => {
696
610
  emissionFailures = [];
697
611
  if (failures.length === 1)
698
612
  throw failures[0];
699
- throw new AggregateError(failures, 'discuss runtime emissions failed');
613
+ throw new AggregateError(failures, 'decide runtime emissions failed');
700
614
  };
701
615
  const drainBoundaryCallsAndEmissions = async () => {
702
616
  while (true) {
@@ -715,22 +629,78 @@ export const createPlaybookRuntime = (options) => {
715
629
  };
716
630
  const requirePorts = () => {
717
631
  if (!ports) {
718
- throw new Error('discuss runtime: init(session) must be called first');
632
+ throw new Error('decide runtime: init(session) must be called first');
719
633
  }
720
634
  return ports;
721
635
  };
722
636
  const requireSessionIdentity = () => {
723
637
  if (!sessionIdentity) {
724
- throw new Error('discuss runtime: init(session) must be called first');
638
+ throw new Error('decide runtime: init(session) must be called first');
725
639
  }
726
640
  return sessionIdentity;
727
641
  };
728
- const currentState = () => {
642
+ const selectPlayerResume = (playerId) => {
643
+ const session = requireSessionIdentity();
644
+ const selected = session.playerSessions
645
+ ? session.playerSessions.select(playerId)
646
+ : playerResumeTokens.get(playerId) ?? false;
647
+ if (selected !== false &&
648
+ (typeof selected !== 'string' || selected.trim().length === 0)) {
649
+ throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
650
+ }
651
+ return selected;
652
+ };
653
+ const updatePlayerResume = (playerId, resumeToken) => {
654
+ const session = requireSessionIdentity();
655
+ if (session.playerSessions) {
656
+ session.playerSessions.update(playerId, resumeToken);
657
+ }
658
+ else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
659
+ playerResumeTokens.set(playerId, resumeToken);
660
+ }
661
+ else {
662
+ playerResumeTokens.delete(playerId);
663
+ }
664
+ };
665
+ const snapshotPlayerResumeTokens = () => {
666
+ const session = requireSessionIdentity();
667
+ const captured = snapshotJsonValue(session.playerSessions
668
+ ? session.playerSessions.snapshot()
669
+ : Object.fromEntries(playerResumeTokens), 'player session store snapshot');
670
+ if (!isPlainObject(captured)) {
671
+ throw new TypeError('player session store snapshot must be an object');
672
+ }
673
+ const tokens = {};
674
+ for (const [playerId, token] of Object.entries(captured)) {
675
+ if (playerId.trim().length === 0) {
676
+ throw new TypeError('player session store snapshot player ids must be non-empty');
677
+ }
678
+ if (typeof token !== 'string' || token.trim().length === 0) {
679
+ throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
680
+ }
681
+ tokens[playerId] = token;
682
+ }
683
+ return tokens;
684
+ };
685
+ const restorePlayerResumeTokens = (tokens) => {
686
+ const session = requireSessionIdentity();
687
+ if (session.playerSessions) {
688
+ session.playerSessions.restore(tokens);
689
+ return;
690
+ }
691
+ playerResumeTokens.clear();
692
+ for (const [playerId, token] of Object.entries(tokens)) {
693
+ playerResumeTokens.set(playerId, token);
694
+ }
695
+ };
696
+ const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
729
697
  const live = actor;
730
698
  if (!live) {
731
- throw new Error('discuss runtime: actor is not initialized');
699
+ throw new Error('decide runtime: actor is not initialized');
732
700
  }
733
- return normalizePlaybookSnapshot(live.getSnapshot());
701
+ return normalizePlaybookSnapshot(live.getSnapshot(), {
702
+ pendingCall,
703
+ });
734
704
  };
735
705
  const stateIdentity = (state) => {
736
706
  return state.stateId === undefined ? {} : { stateId: state.stateId };
@@ -802,7 +772,7 @@ export const createPlaybookRuntime = (options) => {
802
772
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
803
773
  };
804
774
  const queued = await judgeQueue.add(async () => {
805
- // Keep the complete queue task pending until an active host promise
775
+ // Keep the complete queue task pending until an active coder promise
806
776
  // settles. PQueue's signal option may reject add() while that task is
807
777
  // still running, which would let the turn drain race a late finish.
808
778
  signal.throwIfAborted();
@@ -846,9 +816,16 @@ export const createPlaybookRuntime = (options) => {
846
816
  if (inFlightPlayerIds.has(playerId)) {
847
817
  throw new Error(`resolved player "${playerId}" already has an in-flight call`);
848
818
  }
849
- inFlightPlayerIds.add(playerId);
850
819
  const prompt = composePlayerPrompt(input);
851
- const resume = playerResumeTokens.get(playerId) ?? false;
820
+ let resume;
821
+ try {
822
+ signal.throwIfAborted();
823
+ resume = selectPlayerResume(playerId);
824
+ }
825
+ catch (error) {
826
+ latchControlPlaneError(error, signal);
827
+ throw error;
828
+ }
852
829
  const callId = `player-${++playerCallSequence}`;
853
830
  const identity = {
854
831
  purpose: 'captain',
@@ -865,6 +842,7 @@ export const createPlaybookRuntime = (options) => {
865
842
  message: String(error),
866
843
  },
867
844
  }, { turnId: currentTurnId, callId });
845
+ inFlightPlayerIds.add(playerId);
868
846
  try {
869
847
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
870
848
  let rawResult;
@@ -872,14 +850,14 @@ export const createPlaybookRuntime = (options) => {
872
850
  signal.throwIfAborted();
873
851
  const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
874
852
  rawResult = await boundary;
875
- // An XState sibling cancellation does not cancel an arbitrary host
853
+ // An XState sibling cancellation does not cancel an arbitrary coder
876
854
  // promise. Re-check before a late resolution can mutate continuity or
877
855
  // masquerade as a successful boundary finish.
878
856
  signal.throwIfAborted();
879
857
  }
880
858
  catch (error) {
881
859
  // A rejected call produced no authoritative result, so the previous
882
- // token remains untouched. This also covers a host promise that
860
+ // token remains untouched. This also covers a coder promise that
883
861
  // resolves after its invocation signal was cancelled.
884
862
  latchControlPlaneError(error, signal);
885
863
  try {
@@ -900,18 +878,27 @@ export const createPlaybookRuntime = (options) => {
900
878
  await emitFailure(error);
901
879
  }
902
880
  catch {
903
- // The malformed host result remains authoritative.
881
+ // The malformed coder result remains authoritative.
904
882
  }
905
883
  throw error;
906
884
  }
907
885
  // The resolved result is authoritative even on aborted/error status.
908
886
  // Update continuation state before interpreting that status.
909
- if (typeof result.resumeToken === 'string' &&
910
- result.resumeToken.trim().length > 0) {
911
- playerResumeTokens.set(playerId, result.resumeToken);
887
+ try {
888
+ updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
889
+ result.resumeToken.trim().length > 0
890
+ ? result.resumeToken
891
+ : undefined);
912
892
  }
913
- else {
914
- playerResumeTokens.delete(playerId);
893
+ catch (error) {
894
+ latchControlPlaneError(error, signal);
895
+ try {
896
+ await emitFailure(error);
897
+ }
898
+ catch {
899
+ // The continuation-store failure remains authoritative.
900
+ }
901
+ throw error;
915
902
  }
916
903
  // Keep this finish outside the boundary catch. A trace sink can record
917
904
  // the event and then reject; retrying from that catch would duplicate the
@@ -974,14 +961,62 @@ export const createPlaybookRuntime = (options) => {
974
961
  combined.throwIfAborted();
975
962
  try {
976
963
  const prompt = buildAdjudicatorPrompt(input, finalText);
977
- return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
964
+ return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
978
965
  }
979
966
  catch (error) {
980
967
  latchControlPlaneError(error, combined);
981
968
  throw error;
982
969
  }
983
970
  });
984
- const providedMachine = discussMachine.provide({ actors: { player } });
971
+ nestedBridge = createNestedPlaybookBridge({
972
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
973
+ getBoundarySignal: () => currentSignal,
974
+ callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
975
+ emitStarted: async (event) => {
976
+ playbookCallTurnIds.set(event.callId, currentTurnId);
977
+ await emitTrace('playbook.call.started', {
978
+ stateId: event.stateId,
979
+ playbookId: event.playbookId,
980
+ text: event.text,
981
+ }, {
982
+ ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
983
+ callId: event.callId,
984
+ });
985
+ },
986
+ emitFinished: async (event) => {
987
+ const turnId = playbookCallTurnIds.get(event.callId);
988
+ try {
989
+ await emitTrace('playbook.call.finished', {
990
+ stateId: event.stateId,
991
+ playbookId: event.playbookId,
992
+ text: event.text,
993
+ result: event.result,
994
+ }, {
995
+ ...(turnId === undefined ? {} : { turnId }),
996
+ callId: event.callId,
997
+ });
998
+ }
999
+ finally {
1000
+ playbookCallTurnIds.delete(event.callId);
1001
+ }
1002
+ },
1003
+ drain: flush,
1004
+ bindResumeSignal: (signal) => {
1005
+ currentSignal = signal;
1006
+ },
1007
+ onControlPlaneError: (error) => {
1008
+ const signal = currentSignal;
1009
+ if (!signal || !isAbortFailure(error, signal)) {
1010
+ controlPlaneError ??= error;
1011
+ }
1012
+ },
1013
+ onBackgroundError: (error) => {
1014
+ collectFailure(emissionFailures, error);
1015
+ },
1016
+ });
1017
+ const providedMachine = decideMachine.provide({
1018
+ actors: { player, playbook: nestedBridge.actorLogic },
1019
+ });
985
1020
  const inspect = (event) => {
986
1021
  if (event.type !== '@xstate.snapshot')
987
1022
  return;
@@ -989,49 +1024,58 @@ export const createPlaybookRuntime = (options) => {
989
1024
  return;
990
1025
  if (suppressInspectionEmissions)
991
1026
  return;
992
- const snapshot = event.snapshot;
993
- const state = normalizePlaybookSnapshot(snapshot);
994
- const prior = previousState;
995
- previousState = state;
996
- const runtimePorts = ports;
997
- if (!runtimePorts)
998
- return;
999
- const context = snapshot.context;
1000
- const fsmPayload = telemetryPayload(prior, state, event.event, context);
1001
- const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
1002
- void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
1003
- topic: TELEMETRY_TOPIC,
1004
- payload: describedFsmPayload,
1005
- })).catch(() => undefined);
1006
- const priorIds = new Set(prior?.activeStateIds ?? []);
1007
- const pendingQuestions = pendingQuestionsFromContext(context);
1008
- const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
1009
- const scheduleStatus = (message, stateId, data) => {
1010
- const tracePayload = {
1011
- ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1012
- message,
1013
- state,
1014
- ...(data !== undefined ? { data } : {}),
1027
+ try {
1028
+ const snapshot = event.snapshot;
1029
+ const state = normalizePlaybookSnapshot(snapshot);
1030
+ const prior = previousState ?? state;
1031
+ const context = snapshot.context;
1032
+ const fsmPayload = telemetryPayload(prior, state, event.event, context);
1033
+ const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
1034
+ void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
1035
+ topic: TELEMETRY_TOPIC,
1036
+ payload: describedFsmPayload,
1037
+ })).catch(() => undefined);
1038
+ const priorIds = new Set(previousState?.activeStateIds ?? []);
1039
+ previousState = state;
1040
+ const pendingQuestions = pendingQuestionsFromContext(context);
1041
+ const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
1042
+ const scheduleStatus = (message, stateId, data) => {
1043
+ const tracePayload = {
1044
+ ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1045
+ message,
1046
+ state,
1047
+ ...(data !== undefined ? { data } : {}),
1048
+ };
1049
+ assertJsonSafe(tracePayload);
1050
+ void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
1015
1051
  };
1016
- assertJsonSafe(tracePayload);
1017
- void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
1018
- };
1019
- for (const activeStateId of state.activeStateIds) {
1020
- if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
1021
- continue;
1022
- }
1023
- if (WAIT_STATE_IDS.has(activeStateId)) {
1024
- const pending = questionForWaitState(activeStateId, pendingQuestions);
1025
- if (pending) {
1026
- scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1027
- scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
1052
+ for (const activeStateId of state.activeStateIds) {
1053
+ if (priorIds.has(activeStateId) ||
1054
+ !STATUS_STATE_IDS.has(activeStateId)) {
1055
+ continue;
1028
1056
  }
1029
- continue;
1057
+ if (WAIT_STATE_IDS.has(activeStateId)) {
1058
+ const pending = questionForWaitState(activeStateId, pendingQuestions);
1059
+ if (pending) {
1060
+ scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1061
+ scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
1062
+ }
1063
+ continue;
1064
+ }
1065
+ const lastError = activeStateId === 'failed'
1066
+ ? normalizeErrorCompact(context.lastError)
1067
+ : undefined;
1068
+ const description = STATE_DESCRIPTIONS[activeStateId];
1069
+ if (description === undefined)
1070
+ continue;
1071
+ const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
1072
+ scheduleStatus(playerState === undefined
1073
+ ? '◆ workflow failed; awaiting Boss recovery.'
1074
+ : `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1030
1075
  }
1031
- const lastError = activeStateId === 'failed'
1032
- ? normalizeErrorCompact(context.lastError)
1033
- : undefined;
1034
- scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
1076
+ }
1077
+ catch (error) {
1078
+ latchInspectionError(error);
1035
1079
  }
1036
1080
  };
1037
1081
  const createRuntimeActor = (machineSnapshot) => {
@@ -1070,30 +1114,41 @@ export const createPlaybookRuntime = (options) => {
1070
1114
  const driveToQuiescence = async () => {
1071
1115
  const live = actor;
1072
1116
  if (!live)
1073
- throw new Error('discuss runtime: actor is not initialized');
1074
- await waitForPlaybookQuiescence(live);
1117
+ throw new Error('decide runtime: actor is not initialized');
1118
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
1075
1119
  };
1076
1120
  const classify = async (text, signal) => {
1077
1121
  const live = actor;
1078
1122
  if (!live)
1079
- throw new Error('discuss runtime: actor is not initialized');
1123
+ throw new Error('decide runtime: actor is not initialized');
1080
1124
  const snapshot = live.getSnapshot();
1081
1125
  const context = snapshot.context;
1082
- const state = normalizePlaybookSnapshot(snapshot);
1126
+ const state = normalizePlaybookSnapshot(snapshot, {
1127
+ pendingCall: nestedBridge.getPendingCall(),
1128
+ });
1083
1129
  const pendingQuestions = pendingQuestionsFromContext(context);
1130
+ if (pendingQuestions.length === 0 &&
1131
+ (snapshot.status === 'done' ||
1132
+ state.activeStateIds.includes('ready') ||
1133
+ state.activeStateIds.includes('failed'))) {
1134
+ return { type: 'START_DECIDE', callerTopic: text };
1135
+ }
1136
+ if (pendingQuestions.length === 0)
1137
+ return null;
1084
1138
  const prompt = buildClassifierPrompt(text, {
1085
1139
  state,
1086
1140
  pendingQuestions,
1087
1141
  });
1088
1142
  const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
1089
- return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
1143
+ return parseClassification(raw, text, pendingQuestions.map(({ questionId }) => questionId));
1090
1144
  };
1091
1145
  const resultForSnapshot = (signal) => {
1092
1146
  const live = actor;
1093
1147
  if (!live)
1094
- throw new Error('discuss runtime: actor is not initialized');
1148
+ throw new Error('decide runtime: actor is not initialized');
1095
1149
  const snapshot = live.getSnapshot();
1096
- const state = normalizePlaybookSnapshot(snapshot);
1150
+ const pendingCall = nestedBridge.getPendingCall();
1151
+ const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1097
1152
  const context = snapshot.context;
1098
1153
  if (signal?.aborted) {
1099
1154
  return {
@@ -1121,7 +1176,7 @@ export const createPlaybookRuntime = (options) => {
1121
1176
  }
1122
1177
  if (snapshot.status === 'error') {
1123
1178
  throw (snapshot.error ??
1124
- new Error('discuss runtime actor entered error status'));
1179
+ new Error('decide runtime actor entered error status'));
1125
1180
  }
1126
1181
  if (state.activeStateIds.includes('failed')) {
1127
1182
  const error = normalizeErrorFull(context.lastError);
@@ -1131,6 +1186,9 @@ export const createPlaybookRuntime = (options) => {
1131
1186
  ...(error === undefined ? {} : { error }),
1132
1187
  };
1133
1188
  }
1189
+ if (pendingCall) {
1190
+ return { outcome: 'suspended', state, pendingCall };
1191
+ }
1134
1192
  return { outcome: 'quiescent', state };
1135
1193
  };
1136
1194
  // Shared failed-start cleanup for init and restore: stop the actor,
@@ -1139,7 +1197,7 @@ export const createPlaybookRuntime = (options) => {
1139
1197
  // The caller rethrows its original failure. A restore failure skips
1140
1198
  // the disposal trace — the parked session was never re-bound in this
1141
1199
  // process, so its persisted snapshot stays authoritative (DR-014 §2).
1142
- const cleanupFailedStart = async (options) => {
1200
+ const cleanupFailedStart = async (cause, options) => {
1143
1201
  let finalState;
1144
1202
  if (options.emitDisposal && actor) {
1145
1203
  try {
@@ -1155,6 +1213,12 @@ export const createPlaybookRuntime = (options) => {
1155
1213
  catch {
1156
1214
  // Preserve the original startup failure.
1157
1215
  }
1216
+ try {
1217
+ await nestedBridge.abortPending(cause);
1218
+ }
1219
+ catch {
1220
+ // Preserve the original startup failure.
1221
+ }
1158
1222
  try {
1159
1223
  await judgeQueue.onIdle();
1160
1224
  await drainBoundaryCallsAndEmissions();
@@ -1194,6 +1258,8 @@ export const createPlaybookRuntime = (options) => {
1194
1258
  turnSequence = 0;
1195
1259
  judgeCallSequence = 0;
1196
1260
  playerCallSequence = 0;
1261
+ playbookCallSequence = 0;
1262
+ playbookCallTurnIds.clear();
1197
1263
  lifecycleStarted = false;
1198
1264
  };
1199
1265
  return {
@@ -1202,7 +1268,7 @@ export const createPlaybookRuntime = (options) => {
1202
1268
  initInFlight !== undefined ||
1203
1269
  disposed ||
1204
1270
  disposalPromise !== undefined) {
1205
- throw new Error('discuss runtime: init(session) may only be called once');
1271
+ throw new Error('decide runtime: init(session) may only be called once');
1206
1272
  }
1207
1273
  const identity = snapshotPlaybookSession(session);
1208
1274
  let finishInitialization;
@@ -1225,7 +1291,7 @@ export const createPlaybookRuntime = (options) => {
1225
1291
  await flush();
1226
1292
  }
1227
1293
  catch (error) {
1228
- await cleanupFailedStart({ emitDisposal: true });
1294
+ await cleanupFailedStart(error, { emitDisposal: true });
1229
1295
  throw error;
1230
1296
  }
1231
1297
  finally {
@@ -1234,11 +1300,11 @@ export const createPlaybookRuntime = (options) => {
1234
1300
  initInFlight = undefined;
1235
1301
  }
1236
1302
  },
1237
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1303
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1304
+ // session, including one already-started suspended REVIEW call.
1238
1305
  // Defined only at a safe capture point — initialized, not disposing
1239
- // or disposed, no active public boundary, and the actor quiescent
1240
- // with status `active`. DISCUSS never opens nested playbook calls,
1241
- // so no pending-call guard applies.
1306
+ // or disposed, no active public boundary, and the actor quiescent with
1307
+ // status `active`.
1242
1308
  exportSnapshot() {
1243
1309
  if (!actor ||
1244
1310
  !sessionIdentity ||
@@ -1249,22 +1315,49 @@ export const createPlaybookRuntime = (options) => {
1249
1315
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1250
1316
  return undefined;
1251
1317
  }
1318
+ const pendingCall = nestedBridge.getPendingCall();
1319
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
1320
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
1321
+ return undefined;
1322
+ }
1323
+ if (pendingCall !== undefined &&
1324
+ bridgeSuspendedCall !== undefined &&
1325
+ (pendingCall.callId !== bridgeSuspendedCall.callId ||
1326
+ pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
1327
+ pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)) {
1328
+ return undefined;
1329
+ }
1330
+ let suspendedCall;
1331
+ if (bridgeSuspendedCall !== undefined) {
1332
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
1333
+ return undefined;
1334
+ }
1335
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
1336
+ if (bridgeSuspendedCall.turnId !== undefined &&
1337
+ bridgeSuspendedCall.turnId !== turnId) {
1338
+ return undefined;
1339
+ }
1340
+ suspendedCall = {
1341
+ ...bridgeSuspendedCall,
1342
+ ...(turnId === undefined ? {} : { turnId }),
1343
+ };
1344
+ }
1252
1345
  const state = currentState();
1253
1346
  if (state.status !== 'active' || !state.quiescent)
1254
1347
  return undefined;
1255
1348
  const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1256
1349
  const context = actor.getSnapshot().context;
1257
1350
  return {
1258
- schemaVersion: 1,
1351
+ schemaVersion: 2,
1259
1352
  playbookId: sessionIdentity.playbookId,
1260
1353
  machine,
1261
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1354
+ playerResumeTokens: snapshotPlayerResumeTokens(),
1262
1355
  sequences: {
1263
1356
  trace: traceSequence,
1264
1357
  turn: turnSequence,
1265
1358
  judgeCall: judgeCallSequence,
1266
1359
  playerCall: playerCallSequence,
1267
- playbookCall: 0,
1360
+ playbookCall: playbookCallSequence,
1268
1361
  },
1269
1362
  state,
1270
1363
  pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
@@ -1273,6 +1366,7 @@ export const createPlaybookRuntime = (options) => {
1273
1366
  question: pending.question,
1274
1367
  sourceItem: pending.sourceItem,
1275
1368
  })),
1369
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
1276
1370
  };
1277
1371
  },
1278
1372
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
@@ -1285,10 +1379,13 @@ export const createPlaybookRuntime = (options) => {
1285
1379
  initInFlight !== undefined ||
1286
1380
  disposed ||
1287
1381
  disposalPromise !== undefined) {
1288
- throw new Error('discuss runtime: restore(session, snapshot) may only be called once');
1382
+ throw new Error('decide runtime: restore(session, snapshot) may only be called once');
1289
1383
  }
1290
1384
  const identity = snapshotPlaybookSession(session);
1291
- const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
1385
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
1386
+ const suspendedCall = boundSnapshot.schemaVersion === 2
1387
+ ? boundSnapshot.suspendedCall
1388
+ : undefined;
1292
1389
  let finishInitialization;
1293
1390
  const initialization = new Promise((resolve) => {
1294
1391
  finishInitialization = resolve;
@@ -1297,29 +1394,52 @@ export const createPlaybookRuntime = (options) => {
1297
1394
  lifecycleStarted = true;
1298
1395
  ports = identity.ports;
1299
1396
  sessionIdentity = identity;
1397
+ let priorExternalPlayerTokens;
1300
1398
  try {
1301
1399
  traceSequence = boundSnapshot.sequences.trace;
1302
1400
  turnSequence = boundSnapshot.sequences.turn;
1303
1401
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1304
1402
  playerCallSequence = boundSnapshot.sequences.playerCall;
1305
- playerResumeTokens.clear();
1306
- for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1307
- playerResumeTokens.set(playerId, token);
1403
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1404
+ if (identity.playerSessions) {
1405
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1406
+ }
1407
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1408
+ nestedBridge.prepareRestore(suspendedCall);
1409
+ if (suspendedCall !== undefined) {
1410
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1308
1411
  }
1309
1412
  suppressInspectionEmissions = true;
1310
1413
  createRuntimeActor(boundSnapshot.machine);
1311
1414
  actor?.start();
1312
- const restoredState = currentState();
1415
+ const restoredState = currentState(suspendedCall);
1313
1416
  if (restoredState.status !== 'active') {
1314
- throw new Error(`discuss runtime: restored actor status is ${restoredState.status}, expected active`);
1417
+ throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
1418
+ }
1419
+ if (stableJson(restoredState, 'restored runtime state') !==
1420
+ stableJson(boundSnapshot.state, 'runtime snapshot state')) {
1421
+ throw new Error('decide runtime: restored actor state does not match snapshot state');
1315
1422
  }
1316
- suppressInspectionEmissions = false;
1317
1423
  previousState = restoredState;
1318
1424
  await flush();
1425
+ suppressInspectionEmissions = false;
1426
+ // Final fallible step: after this publication the authoritative
1427
+ // child has rejoined ordinary resume/abort ownership, so no later
1428
+ // restore validation may trigger failed-start rollback.
1429
+ nestedBridge.confirmRestore();
1319
1430
  }
1320
1431
  catch (error) {
1321
- await cleanupFailedStart({ emitDisposal: false });
1322
- throw error;
1432
+ let failure = error;
1433
+ if (priorExternalPlayerTokens !== undefined) {
1434
+ try {
1435
+ identity.playerSessions.restore(priorExternalPlayerTokens);
1436
+ }
1437
+ catch (rollbackError) {
1438
+ failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
1439
+ }
1440
+ }
1441
+ await cleanupFailedStart(failure, { emitDisposal: false });
1442
+ throw failure;
1323
1443
  }
1324
1444
  finally {
1325
1445
  finishInitialization();
@@ -1329,14 +1449,14 @@ export const createPlaybookRuntime = (options) => {
1329
1449
  },
1330
1450
  async handleBossInput(turn) {
1331
1451
  if (disposalPromise !== undefined) {
1332
- throw new Error('discuss runtime: runtime is disposing or disposed');
1452
+ throw new Error('decide runtime: runtime is disposing or disposed');
1333
1453
  }
1334
1454
  requirePorts();
1335
1455
  if (!actor) {
1336
- throw new Error('discuss runtime: init(session) must be called before handleBossInput');
1456
+ throw new Error('decide runtime: init(session) must be called before handleBossInput');
1337
1457
  }
1338
- if (currentTurnId !== undefined) {
1339
- throw new Error('discuss runtime: another runtime turn is active');
1458
+ if (currentSignal !== undefined) {
1459
+ throw new Error('decide runtime: another runtime turn is active');
1340
1460
  }
1341
1461
  const turnId = ++turnSequence;
1342
1462
  currentTurnId = turnId;
@@ -1358,6 +1478,9 @@ export const createPlaybookRuntime = (options) => {
1358
1478
  await emitBoundaryStatus('No playbook action classified.', state);
1359
1479
  result = { outcome: 'no-action', state };
1360
1480
  }
1481
+ else if (event.type === 'NO_ACTION') {
1482
+ result = { outcome: 'no-action', state: currentState() };
1483
+ }
1361
1484
  else {
1362
1485
  await emitBoundaryStatus(event.type, currentState());
1363
1486
  if (actor.getSnapshot().status === 'done') {
@@ -1446,25 +1569,68 @@ export const createPlaybookRuntime = (options) => {
1446
1569
  if (failures.length === 1)
1447
1570
  throw failures[0];
1448
1571
  if (failures.length > 1) {
1449
- throw new AggregateError(failures, 'discuss runtime turn failed');
1572
+ throw new AggregateError(failures, 'decide runtime turn failed');
1450
1573
  }
1451
1574
  return result;
1452
1575
  },
1453
- async resumePlaybookCall({ callId, }) {
1576
+ async resumePlaybookCall({ callId, result: childResult, signal, }) {
1454
1577
  if (disposalPromise !== undefined) {
1455
- throw new Error('discuss runtime: runtime is disposing or disposed');
1578
+ throw new Error('decide runtime: runtime is disposing or disposed');
1456
1579
  }
1457
1580
  requirePorts();
1458
1581
  if (!actor) {
1459
- throw new Error('discuss runtime: init(session) must be called before resumePlaybookCall');
1582
+ throw new Error('decide runtime: init(session) must be called before resumePlaybookCall');
1583
+ }
1584
+ if (currentSignal !== undefined) {
1585
+ throw new Error('decide runtime: another runtime turn is active');
1586
+ }
1587
+ currentTurnId = playbookCallTurnIds.get(callId);
1588
+ currentSignal = signal;
1589
+ controlPlaneError = undefined;
1590
+ let runResult;
1591
+ let operationError;
1592
+ try {
1593
+ await nestedBridge.resume({
1594
+ callId,
1595
+ result: childResult,
1596
+ signal,
1597
+ });
1598
+ }
1599
+ catch (error) {
1600
+ operationError = error;
1601
+ }
1602
+ try {
1603
+ await waitForPlaybookQuiescence(actor, {
1604
+ pendingCalls: nestedBridge,
1605
+ });
1606
+ runResult = resultForSnapshot(signal);
1607
+ }
1608
+ catch (error) {
1609
+ operationError ??= error;
1460
1610
  }
1461
- throw new Error(`unknown or stale playbook call id ${callId}`);
1611
+ let drainError;
1612
+ try {
1613
+ await drainBoundaryCallsAndEmissions();
1614
+ }
1615
+ catch (error) {
1616
+ drainError = error;
1617
+ }
1618
+ const failure = controlPlaneError ?? drainError ?? operationError;
1619
+ currentSignal = undefined;
1620
+ currentTurnId = undefined;
1621
+ controlPlaneError = undefined;
1622
+ if (failure !== undefined)
1623
+ throw failure;
1624
+ if (runResult === undefined) {
1625
+ throw new Error('decide runtime: playbook resume produced no result');
1626
+ }
1627
+ return runResult;
1462
1628
  },
1463
1629
  dispose() {
1464
1630
  if (disposalPromise !== undefined)
1465
1631
  return disposalPromise;
1466
- if (currentTurnId !== undefined) {
1467
- return Promise.reject(new Error('discuss runtime: cannot dispose while a runtime turn is active'));
1632
+ if (currentSignal !== undefined) {
1633
+ return Promise.reject(new Error('decide runtime: cannot dispose while a runtime turn is active'));
1468
1634
  }
1469
1635
  disposalPromise = (async () => {
1470
1636
  const initialization = initInFlight;
@@ -1477,6 +1643,12 @@ export const createPlaybookRuntime = (options) => {
1477
1643
  const finalState = currentState();
1478
1644
  const failures = [];
1479
1645
  stopActor();
1646
+ try {
1647
+ await nestedBridge.dispose();
1648
+ }
1649
+ catch (error) {
1650
+ collectFailure(failures, error);
1651
+ }
1480
1652
  try {
1481
1653
  await drainBoundaryCallsAndEmissions();
1482
1654
  }
@@ -1500,6 +1672,7 @@ export const createPlaybookRuntime = (options) => {
1500
1672
  }
1501
1673
  finally {
1502
1674
  playerResumeTokens.clear();
1675
+ playbookCallTurnIds.clear();
1503
1676
  inFlightPlayerIds.clear();
1504
1677
  activeBoundaryCalls.clear();
1505
1678
  activeEmissionCalls.clear();
@@ -1517,7 +1690,7 @@ export const createPlaybookRuntime = (options) => {
1517
1690
  if (failures.length === 1)
1518
1691
  throw failures[0];
1519
1692
  if (failures.length > 1) {
1520
- throw new AggregateError(failures, 'discuss runtime disposal failed');
1693
+ throw new AggregateError(failures, 'decide runtime disposal failed');
1521
1694
  }
1522
1695
  })();
1523
1696
  return disposalPromise;
@@ -1538,10 +1711,10 @@ export const _internal = {
1538
1711
  normalizeErrorCompact,
1539
1712
  normalizeErrorFull,
1540
1713
  DEFAULT_PLAYER_BINDING,
1541
- ALIAS_RESOLUTION,
1542
1714
  STATE_DESCRIPTIONS,
1543
- CAPTAIN_STATES,
1544
- CAPTAIN_STATE_IDS,
1715
+ PLAYER_STATES,
1716
+ PLAYER_STATE_IDS,
1717
+ VERBATIM_PAYLOAD_FIELDS,
1545
1718
  BOSS_INTERRUPT_TARGETS,
1546
1719
  CONTINUATION_PREAMBLE,
1547
1720
  TELEMETRY_TOPIC,