@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,24 +1,20 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  //
4
- // PlaybookRuntime for the discuss playbook, linked from the FSM artifact by
4
+ // PlaybookRuntime for the decide playbook, linked from the FSM artifact by
5
5
  // the slc FSM-to-runtime link phase.
6
6
  //
7
7
  // Linker inputs:
8
- // FSM artifact: ./discuss.fsm.ts
9
- // Link target: @sublang/playbook/src/runtime.ts
10
- // Player binding: Host -> host, Participant -> participant,
11
- // Committer -> committer
8
+ // FSM artifact: ./decide.fsm.ts
9
+ // Link target: @sublang/playbook/runtime
10
+ // Player binding: Coder -> coder, Reviewer -> reviewer
12
11
  // (default binding: lowercased player name)
13
- // Composite players: Committer = Host | Participant. DISCUSS-14 and
14
- // DISCUSS-15 keep PlayerInput.player = 'Committer';
15
- // callPlayer resolution uses options.committer when
16
- // supplied, otherwise falls back to Host, the first
17
- // listed alias alternative.
18
12
  // Adjudication: LLM-judge per state (default)
19
13
  // Boss-event mapping: free-text judge classification (default)
20
14
  // Abort strategy: natural rejection; every player-invoking state's
21
15
  // onError routes to the quiescent failed state.
16
+ // Output profile: bespoke parallel runtime with the shared nested-call
17
+ // bridge (slc/link.md §Output)
22
18
 
23
19
  import PQueue from 'p-queue';
24
20
  import { createActor, fromPromise } from 'xstate';
@@ -28,6 +24,7 @@ import {
28
24
  assertJsonSafe,
29
25
  assertPlaybookRuntimeSnapshot,
30
26
  combineAbortSignals,
27
+ createNestedPlaybookBridge,
31
28
  detachPersistedMachineSnapshot,
32
29
  normalizeError,
33
30
  normalizePlaybookSnapshot,
@@ -36,14 +33,16 @@ import {
36
33
  validatePlayerResult,
37
34
  waitForPlaybookQuiescence,
38
35
  } from '../../../src/xstate-runtime.js';
36
+ import type { NestedPlaybookBridge } from '../../../src/xstate-runtime.js';
39
37
 
40
- import discussMachine, {
38
+ import decideMachine, {
41
39
  type PlayerInput,
42
40
  type PlayerOutput,
43
- type DiscussEvent,
44
- type DiscussInput,
41
+ type DecideEvent,
42
+ type DecideInput,
45
43
  type PendingBossQuestion,
46
- } from './discuss.fsm.js';
44
+ type PlaybookInput,
45
+ } from './decide.fsm.js';
47
46
 
48
47
  import type {
49
48
  CaptainCallOptions,
@@ -52,9 +51,13 @@ import type {
52
51
  NormalizedError,
53
52
  PlayerCallOptions,
54
53
  PlayerResult,
54
+ PlayerSessionStore,
55
55
  PlaybookCallRequest,
56
56
  PlaybookCallResult,
57
57
  PlaybookCallStart,
58
+ PlaybookControlAction,
59
+ PlaybookControlReceipt,
60
+ PlaybookControlView,
58
61
  PlaybookPendingCall,
59
62
  PlaybookPorts,
60
63
  PlaybookRunResult,
@@ -75,9 +78,13 @@ export type {
75
78
  NormalizedError,
76
79
  PlayerCallOptions,
77
80
  PlayerResult,
81
+ PlayerSessionStore,
78
82
  PlaybookCallRequest,
79
83
  PlaybookCallResult,
80
84
  PlaybookCallStart,
85
+ PlaybookControlAction,
86
+ PlaybookControlReceipt,
87
+ PlaybookControlView,
81
88
  PlaybookPendingCall,
82
89
  PlaybookPorts,
83
90
  PlaybookRunResult,
@@ -91,68 +98,53 @@ export type {
91
98
  PlaybookTraceType,
92
99
  };
93
100
 
94
- type PlayerName = 'Host' | 'Participant' | 'Committer';
95
- type ReviewScope = 'specItems' | 'decisionRecords' | 'mixed';
101
+ type PlayerName = 'Coder' | 'Reviewer';
96
102
 
97
- export interface PlaybookRuntimeOptions extends DiscussInput {
103
+ export interface PlaybookRuntimeOptions extends DecideInput {
98
104
  playerBinding?: Partial<Record<PlayerName, string>>;
99
105
  }
100
106
 
101
107
  const DEFAULT_PLAYER_BINDING: Readonly<Record<PlayerName, string>> = {
102
- Host: 'host',
103
- Participant: 'participant',
104
- Committer: 'committer',
108
+ Coder: 'coder',
109
+ Reviewer: 'reviewer',
105
110
  };
106
111
 
107
- const ALIAS_RESOLUTION: Readonly<Record<string, string>> = {
108
- 'DISCUSS-14':
109
- 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
110
- 'DISCUSS-15':
111
- 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
112
- };
113
-
114
- function snapshotDiscussRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
115
- const captured = snapshotJsonValue(value, 'DISCUSS runtime options');
112
+ function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
113
+ const captured = snapshotJsonValue(value, 'DECIDE runtime options');
116
114
  if (!isPlainObject(captured)) {
117
- throw new TypeError('DISCUSS runtime options must be an object');
115
+ throw new TypeError('DECIDE runtime options must be an object');
118
116
  }
119
- const allowed = new Set([
120
- 'host',
121
- 'participant',
122
- 'committer',
123
- 'playerBinding',
124
- ]);
117
+ const allowed = new Set(['coderLlm', 'playerBinding']);
125
118
  for (const key of Object.keys(captured)) {
126
119
  if (!allowed.has(key)) {
127
- throw new TypeError(`DISCUSS runtime options.${key} is not declared`);
120
+ throw new TypeError(`DECIDE runtime options.${key} is not declared`);
128
121
  }
129
122
  }
130
- for (const key of ['host', 'participant', 'committer'] as const) {
131
- if (key in captured && typeof captured[key] !== 'string') {
132
- throw new TypeError(`DISCUSS runtime options.${key} must be a string`);
133
- }
123
+ if (
124
+ typeof captured.coderLlm !== 'string' ||
125
+ captured.coderLlm.trim().length === 0
126
+ ) {
127
+ throw new TypeError(
128
+ 'DECIDE runtime options.coderLlm must be a non-empty string',
129
+ );
134
130
  }
135
131
  if ('playerBinding' in captured) {
136
132
  const playerBinding = captured.playerBinding;
137
133
  if (!isPlainObject(playerBinding)) {
138
134
  throw new TypeError(
139
- 'DISCUSS runtime options.playerBinding must be an object',
135
+ 'DECIDE runtime options.playerBinding must be an object',
140
136
  );
141
137
  }
142
- const playerNames = new Set<PlayerName>([
143
- 'Host',
144
- 'Participant',
145
- 'Committer',
146
- ]);
138
+ const playerNames = new Set<PlayerName>(['Coder', 'Reviewer']);
147
139
  for (const [player, playerId] of Object.entries(playerBinding)) {
148
140
  if (!playerNames.has(player as PlayerName)) {
149
141
  throw new TypeError(
150
- `DISCUSS runtime options.playerBinding.${player} is not declared`,
142
+ `DECIDE runtime options.playerBinding.${player} is not declared`,
151
143
  );
152
144
  }
153
145
  if (typeof playerId !== 'string' || playerId.trim().length === 0) {
154
146
  throw new TypeError(
155
- `DISCUSS runtime options.playerBinding.${player} must be a non-empty string`,
147
+ `DECIDE runtime options.playerBinding.${player} must be a non-empty string`,
156
148
  );
157
149
  }
158
150
  }
@@ -161,140 +153,40 @@ function snapshotDiscussRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
161
153
  }
162
154
 
163
155
  const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
164
- ready: 'Idle hub awaiting a Boss discussion or review directive.',
165
- askHostInitial:
166
- 'Host proposes whether the Boss topic should become spec items or DRs.',
167
- askParticipantInitial:
168
- 'Participant independently proposes whether the Boss topic should become spec items or DRs.',
169
- hostInitialRound:
170
- 'Host reconciles the Participant proposal during initial discussion.',
171
- participantInitialRound:
172
- 'Participant reconciles the Host proposal during initial discussion.',
173
- hostWritesAgreement:
174
- 'Host writes the agreed spec items or DRs and updates the spec map.',
175
- commitInitialChanges:
176
- 'Committer commits the changes produced at the end of initial discussion.',
177
- reviewSpecInitialCommit:
178
- 'Participant reviews newly committed spec-item changes.',
179
- reviewSpecHostChanges:
180
- 'Participant reviews Host changes to spec items after findings.',
181
- reviewDrInitialCommit:
182
- 'Participant reviews newly committed decision-record changes.',
183
- reviewDrHostChanges:
184
- 'Participant reviews Host changes to decision records after findings.',
185
- reviewMixedInitialCommit:
186
- 'Participant reviews newly committed mixed spec-item and DR changes.',
187
- reviewMixedHostChanges:
188
- 'Participant reviews Host changes to mixed spec items and DRs after findings.',
189
- hostAddressesFindings:
190
- 'Host accepts or challenges review findings and stages repo changes.',
191
- participantAddressesRebuttals:
192
- 'Participant accepts or challenges Host rebuttals.',
193
- commitReviewedChanges:
194
- 'Committer commits reviewed changes once Participant raises no findings.',
195
- awaitBossReply: 'Waiting for Boss to answer a player question.',
196
- failed: 'The discussion workflow failed and is waiting for Boss recovery.',
197
- done: 'The discussion workflow completed with a reviewed commit.',
156
+ ready: 'Waiting for a topic to decide.',
157
+ askCoderProposal: 'Coder independently proposes a spec design.',
158
+ askReviewerProposal: 'Reviewer independently proposes a spec design.',
159
+ waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
160
+ waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
161
+ commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
162
+ awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
163
+ reviewCommit: 'REVIEW examines the committed proposal.',
164
+ failed: 'DECIDE failed and is waiting for a new topic.',
165
+ reportedReviewFailure:
166
+ 'DECIDE reports REVIEW’s failure and its last commit.',
167
+ done: 'DECIDE completed with an approved commit.',
198
168
  };
199
169
 
200
- const CAPTAIN_STATES = [
201
- { stateId: 'askHostInitial', player: 'Host', sourceItem: 'DISCUSS-1' },
202
- {
203
- stateId: 'askParticipantInitial',
204
- player: 'Participant',
205
- sourceItem: 'DISCUSS-2',
206
- },
207
- { stateId: 'hostInitialRound', player: 'Host', sourceItem: 'DISCUSS-3' },
208
- {
209
- stateId: 'participantInitialRound',
210
- player: 'Participant',
211
- sourceItem: 'DISCUSS-4',
212
- },
213
- { stateId: 'hostWritesAgreement', player: 'Host', sourceItem: 'DISCUSS-5' },
214
- {
215
- stateId: 'commitInitialChanges',
216
- player: 'Committer',
217
- sourceItem: 'DISCUSS-14',
218
- },
219
- {
220
- stateId: 'reviewSpecInitialCommit',
221
- player: 'Participant',
222
- sourceItem: 'DISCUSS-6',
223
- },
224
- {
225
- stateId: 'reviewSpecHostChanges',
226
- player: 'Participant',
227
- sourceItem: 'DISCUSS-7',
228
- },
170
+ const PLAYER_STATES = [
171
+ { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
229
172
  {
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;
@@ -431,6 +324,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
431
324
  return value !== null && typeof value === 'object' && !Array.isArray(value);
432
325
  }
433
326
 
327
+ function sortJson(value: JsonValue): JsonValue {
328
+ if (Array.isArray(value)) return value.map((entry) => sortJson(entry));
329
+ if (value !== null && typeof value === 'object') {
330
+ const record = value as { readonly [key: string]: JsonValue };
331
+ const sorted: Record<string, JsonValue> = {};
332
+ for (const key of Object.keys(record).sort()) {
333
+ sorted[key] = sortJson(record[key]);
334
+ }
335
+ return sorted;
336
+ }
337
+ return value;
338
+ }
339
+
340
+ function stableJson(value: unknown, path: string): string {
341
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
342
+ }
343
+
434
344
  function stripCodeFence(text: string): string {
435
345
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
436
346
  return fence ? fence[1].trim() : text;
@@ -492,7 +402,7 @@ function buildClassifierPrompt(
492
402
  },
493
403
  ): string {
494
404
  const lines: string[] = [];
495
- lines.push('You are the Boss-input classifier for the discuss playbook.');
405
+ lines.push('You are the Boss-input classifier for the decide playbook.');
496
406
  lines.push(
497
407
  'Classify the Boss message into exactly one FSM event, or into no event.',
498
408
  );
@@ -503,7 +413,7 @@ function buildClassifierPrompt(
503
413
  lines.push('Pending Boss questions:');
504
414
  for (const pending of ctx.pendingQuestions) {
505
415
  lines.push(
506
- `- ${pending.questionId} (${pending.player}, ${pending.sourceItem}): ${pending.question}`,
416
+ `- ${pending.questionId} (${pending.player}): ${pending.question}`,
507
417
  );
508
418
  }
509
419
  lines.push(
@@ -513,98 +423,77 @@ function buildClassifierPrompt(
513
423
  lines.push('');
514
424
  lines.push('Events and payload contracts:');
515
425
  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.',
520
- );
521
- lines.push(
522
- `- BOSS_INTERRUPT: required targetId string, one of ${BOSS_INTERRUPT_TARGETS.join(', ')}.`,
426
+ `- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`,
523
427
  );
524
428
  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.',
429
+ '- 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
430
  );
431
+ lines.push('- NO_ACTION: no fields.');
527
432
  lines.push('');
528
433
  lines.push('Boss message:');
529
434
  lines.push(text);
530
435
  lines.push('');
531
436
  lines.push(
532
- 'Reply with a single JSON object: { "event": "<EVENT_TYPE or null>", ...payload fields }.',
437
+ 'Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.',
533
438
  );
534
- lines.push('Use null when no FSM action applies.');
439
+ lines.push('Use NO_ACTION when no FSM action applies.');
535
440
  return lines.join('\n');
536
441
  }
537
442
 
538
443
  function parseClassification(
539
444
  raw: string,
445
+ text: string,
540
446
  pendingQuestionIds: readonly string[] = [],
541
- ): DiscussEvent | null {
447
+ ): DecideEvent | { type: 'NO_ACTION' } | null {
542
448
  const obj = extractJson(raw);
543
449
  if (!obj) return null;
544
- const eventType = obj.event ?? obj.type;
545
-
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
- }
450
+ const exactKeys = (...keys: string[]): boolean => {
451
+ const ownKeys = Reflect.ownKeys(obj);
452
+ return (
453
+ ownKeys.length === keys.length &&
454
+ ownKeys.every(
455
+ (key) => typeof key === 'string' && keys.includes(key),
456
+ )
457
+ );
458
+ };
459
+ const eventType = obj.type;
561
460
 
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;
461
+ if (eventType === 'NO_ACTION') {
462
+ return exactKeys('type') ? { type: 'NO_ACTION' } : null;
578
463
  }
579
464
 
580
465
  if (eventType === 'BOSS_INTERRUPT') {
581
466
  if (
467
+ exactKeys('type', 'targetId') &&
582
468
  typeof obj.targetId === 'string' &&
583
469
  BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)
584
470
  ) {
585
- return { type: 'BOSS_INTERRUPT', targetId: obj.targetId as never };
471
+ return {
472
+ type: 'BOSS_INTERRUPT',
473
+ targetId: obj.targetId as 'independentProposals',
474
+ bossIntent: text,
475
+ };
586
476
  }
587
477
  return null;
588
478
  }
589
479
 
590
480
  if (eventType === 'BOSS_REPLY') {
591
- if (typeof obj.answer !== 'string' || pendingQuestionIds.length === 0) {
592
- return null;
593
- }
481
+ if (pendingQuestionIds.length === 0) return null;
594
482
  if (typeof obj.questionId === 'string') {
595
- return pendingQuestionIds.includes(obj.questionId)
483
+ return exactKeys('type', 'questionId') &&
484
+ pendingQuestionIds.includes(obj.questionId)
596
485
  ? {
597
486
  type: 'BOSS_REPLY',
598
487
  questionId: obj.questionId as never,
599
- answer: obj.answer,
488
+ answer: text,
600
489
  }
601
490
  : null;
602
491
  }
603
- return pendingQuestionIds.length === 1
492
+ return exactKeys('type') && pendingQuestionIds.length === 1
604
493
  ? {
605
494
  type: 'BOSS_REPLY',
606
495
  questionId: pendingQuestionIds[0] as never,
607
- answer: obj.answer,
496
+ answer: text,
608
497
  }
609
498
  : null;
610
499
  }
@@ -639,6 +528,18 @@ function buildAdjudicatorPrompt(
639
528
  for (const [guard, description] of Object.entries(input.result)) {
640
529
  lines.push(`- ${guard}: ${description}`);
641
530
  }
531
+ const runtimeOwnedFields = new Set<string>();
532
+ for (const description of Object.values(input.result)) {
533
+ for (const field of requiredFieldsFor(description)) {
534
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) runtimeOwnedFields.add(field);
535
+ }
536
+ }
537
+ if (runtimeOwnedFields.size > 0) {
538
+ lines.push('');
539
+ lines.push(
540
+ `The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`,
541
+ );
542
+ }
642
543
  lines.push('');
643
544
  lines.push(
644
545
  'Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.',
@@ -646,7 +547,11 @@ function buildAdjudicatorPrompt(
646
547
  return lines.join('\n');
647
548
  }
648
549
 
649
- function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
550
+ function parseAdjudication(
551
+ raw: string,
552
+ input: PlayerInput,
553
+ finalText: string,
554
+ ): PlayerOutput {
650
555
  const obj = extractJson(raw);
651
556
  if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
652
557
  throw new Error('adjudicator returned empty or malformed JSON');
@@ -659,15 +564,22 @@ function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
659
564
  );
660
565
  }
661
566
 
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];
567
+ const requiredFields = requiredFieldsFor(input.result[guard]);
568
+ const allowedFields = new Set(['guard', ...requiredFields]);
569
+ for (const key of Reflect.ownKeys(obj)) {
570
+ if (typeof key !== 'string' || !allowedFields.has(key)) {
571
+ throw new Error(
572
+ `adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`,
573
+ );
574
+ }
575
+ }
576
+ const output: Record<string, unknown> = { guard };
577
+ for (const field of requiredFields) {
578
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
579
+ output[field] = finalText;
580
+ continue;
581
+ }
582
+ const value = obj[field];
671
583
  if (
672
584
  value === undefined ||
673
585
  value === null ||
@@ -677,19 +589,9 @@ function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
677
589
  `adjudicator response for guard "${guard}" missing required field "${field}"`,
678
590
  );
679
591
  }
592
+ output[field] = value;
680
593
  }
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;
594
+ return output as PlayerOutput;
693
595
  }
694
596
 
695
597
  function combineSignals(
@@ -722,9 +624,7 @@ function isEmptyFinalText(finalText: string | undefined): boolean {
722
624
  }
723
625
 
724
626
  function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
725
- if (!signal.aborted) return false;
726
- if (Object.is(error, signal.reason)) return true;
727
- return normalizeErrorCompact(error)?.name === 'AbortError';
627
+ return signal.aborted && Object.is(error, signal.reason);
728
628
  }
729
629
 
730
630
  function pendingQuestionsFromContext(
@@ -761,10 +661,8 @@ function pendingQuestionsFromContext(
761
661
  }
762
662
 
763
663
  const WAIT_STATE_RESUME_IDS: Readonly<Record<string, string>> = {
764
- waitHostInitialReply: 'askHostInitial',
765
- waitParticipantInitialReply: 'askParticipantInitial',
766
- waitHostReconciliationReply: 'hostInitialRound',
767
- waitParticipantReconciliationReply: 'participantInitialRound',
664
+ waitCoderProposalReply: 'askCoderProposal',
665
+ waitReviewerProposalReply: 'askReviewerProposal',
768
666
  };
769
667
 
770
668
  const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
@@ -773,7 +671,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
773
671
  ]);
774
672
 
775
673
  const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
776
- ...CAPTAIN_STATE_IDS,
674
+ ...PLAYER_STATE_IDS,
777
675
  ...WAIT_STATE_IDS,
778
676
  'failed',
779
677
  ]);
@@ -793,25 +691,36 @@ function questionForWaitState(
793
691
  : undefined;
794
692
  }
795
693
 
796
- function normalizedEventType(event: unknown): JsonValue {
797
- if (
798
- event !== null &&
799
- typeof event === 'object' &&
800
- !Array.isArray(event) &&
801
- 'type' in event
802
- ) {
803
- const type = (event as { type: unknown }).type;
804
- return typeof type === 'string' ? type : String(type);
694
+ const TRANSITION_EVENT_FIELDS: readonly string[] = [
695
+ 'callerTopic',
696
+ 'targetId',
697
+ 'bossIntent',
698
+ 'questionId',
699
+ 'answer',
700
+ ];
701
+
702
+ function normalizedTransitionEvent(event: unknown): JsonValue {
703
+ if (event === null || typeof event !== 'object') {
704
+ return snapshotJsonValue(event ?? null, 'FSM event');
805
705
  }
806
- if (
807
- event === null ||
808
- typeof event === 'string' ||
809
- typeof event === 'boolean' ||
810
- (typeof event === 'number' && Number.isFinite(event))
811
- ) {
812
- return event;
706
+ const source = event as Record<string, unknown>;
707
+ const descriptor: Record<string, JsonValue> = {};
708
+ if (typeof source.type === 'string') descriptor.type = source.type;
709
+ for (const field of TRANSITION_EVENT_FIELDS) {
710
+ if (typeof source[field] === 'string') {
711
+ descriptor[field] = source[field] as string;
712
+ }
813
713
  }
814
- return String(event);
714
+ if (source.output !== undefined) {
715
+ descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
716
+ }
717
+ if (source.error !== undefined) {
718
+ descriptor.error = snapshotJsonValue(
719
+ normalizeError(source.error),
720
+ 'FSM event error',
721
+ );
722
+ }
723
+ return snapshotJsonValue(descriptor, 'FSM event');
815
724
  }
816
725
 
817
726
  function telemetryPayload(
@@ -820,22 +729,15 @@ function telemetryPayload(
820
729
  event: unknown,
821
730
  context: Record<string, unknown>,
822
731
  ): JsonValue {
823
- const eventError =
824
- event !== null &&
825
- typeof event === 'object' &&
826
- !Array.isArray(event) &&
827
- 'error' in event
828
- ? normalizeErrorFull((event as { error: unknown }).error)
829
- : undefined;
830
732
  const pendingBossQuestions = pendingQuestionsFromContext(context);
733
+ const prior = previousState ?? state;
831
734
  const payload = {
832
- from: previousState?.value ?? null,
735
+ from: prior.value,
833
736
  to: state.value,
834
- event: normalizedEventType(event),
835
- previousState: previousState ?? null,
737
+ event: normalizedTransitionEvent(event),
738
+ previousState: prior,
836
739
  state,
837
740
  ...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
838
- ...(eventError !== undefined ? { error: eventError } : {}),
839
741
  ...(state.activeStateIds.includes('failed')
840
742
  ? { lastError: normalizeErrorFull(context.lastError) ?? null }
841
743
  : {}),
@@ -847,15 +749,13 @@ function telemetryPayload(
847
749
  export const createPlaybookRuntime: PlaybookRuntimeFactory<
848
750
  PlaybookRuntimeOptions
849
751
  > = (options) => {
850
- const boundOptions = snapshotDiscussRuntimeOptions(options);
752
+ const boundOptions = snapshotDecideRuntimeOptions(options);
851
753
  const binding: Record<PlayerName, string> = {
852
754
  ...DEFAULT_PLAYER_BINDING,
853
755
  ...(boundOptions.playerBinding ?? {}),
854
756
  };
855
- const fsmInput: DiscussInput = {
856
- host: boundOptions.host,
857
- participant: boundOptions.participant,
858
- committer: boundOptions.committer,
757
+ const fsmInput: DecideInput = {
758
+ coderLlm: boundOptions.coderLlm,
859
759
  };
860
760
 
861
761
  type SessionIdentity = Readonly<PlaybookSession>;
@@ -872,12 +772,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
872
772
  let turnSequence = 0;
873
773
  let judgeCallSequence = 0;
874
774
  let playerCallSequence = 0;
775
+ let playbookCallSequence = 0;
875
776
  let lifecycleStarted = false;
876
777
  let initInFlight: Promise<void> | undefined;
877
778
  let disposed = false;
878
779
  let disposalPromise: Promise<void> | undefined;
879
780
  let controlPlaneError: unknown;
781
+ let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
880
782
  const playerResumeTokens = new Map<string, string>();
783
+ const playbookCallTurnIds = new Map<string, number | undefined>();
881
784
  const inFlightPlayerIds = new Set<string>();
882
785
  const activeBoundaryCalls = new Set<Promise<unknown>>();
883
786
  const activeEmissionCalls = new Set<Promise<void>>();
@@ -899,6 +802,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
899
802
  ): void => {
900
803
  if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
901
804
  };
805
+ const latchInspectionError = (error: unknown): void => {
806
+ if (currentSignal !== undefined) {
807
+ latchControlPlaneError(error, currentSignal);
808
+ } else {
809
+ collectFailure(emissionFailures, error);
810
+ }
811
+ };
902
812
  const enqueue = (fn: () => Promise<void>): Promise<void> => {
903
813
  const queued = emissionQueue.add(fn);
904
814
  activeEmissionCalls.add(queued);
@@ -928,7 +838,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
928
838
  const failures = emissionFailures;
929
839
  emissionFailures = [];
930
840
  if (failures.length === 1) throw failures[0];
931
- throw new AggregateError(failures, 'discuss runtime emissions failed');
841
+ throw new AggregateError(failures, 'decide runtime emissions failed');
932
842
  };
933
843
  const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
934
844
  while (true) {
@@ -949,22 +859,95 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
949
859
  };
950
860
  const requirePorts = (): PlaybookPorts => {
951
861
  if (!ports) {
952
- throw new Error('discuss runtime: init(session) must be called first');
862
+ throw new Error('decide runtime: init(session) must be called first');
953
863
  }
954
864
  return ports;
955
865
  };
956
866
  const requireSessionIdentity = (): SessionIdentity => {
957
867
  if (!sessionIdentity) {
958
- throw new Error('discuss runtime: init(session) must be called first');
868
+ throw new Error('decide runtime: init(session) must be called first');
959
869
  }
960
870
  return sessionIdentity;
961
871
  };
962
- const currentState = (): PlaybookState => {
872
+ const selectPlayerResume = (playerId: string): string | false => {
873
+ const session = requireSessionIdentity();
874
+ const selected = session.playerSessions
875
+ ? session.playerSessions.select(playerId)
876
+ : playerResumeTokens.get(playerId) ?? false;
877
+ if (
878
+ selected !== false &&
879
+ (typeof selected !== 'string' || selected.trim().length === 0)
880
+ ) {
881
+ throw new TypeError(
882
+ `player session store returned an invalid resume token for ${playerId}`,
883
+ );
884
+ }
885
+ return selected;
886
+ };
887
+ const updatePlayerResume = (
888
+ playerId: string,
889
+ resumeToken: string | undefined,
890
+ ): void => {
891
+ const session = requireSessionIdentity();
892
+ if (session.playerSessions) {
893
+ session.playerSessions.update(playerId, resumeToken);
894
+ } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
895
+ playerResumeTokens.set(playerId, resumeToken);
896
+ } else {
897
+ playerResumeTokens.delete(playerId);
898
+ }
899
+ };
900
+ const snapshotPlayerResumeTokens = (): Record<string, string> => {
901
+ const session = requireSessionIdentity();
902
+ const captured = snapshotJsonValue(
903
+ session.playerSessions
904
+ ? session.playerSessions.snapshot()
905
+ : Object.fromEntries(playerResumeTokens),
906
+ 'player session store snapshot',
907
+ );
908
+ if (!isPlainObject(captured)) {
909
+ throw new TypeError('player session store snapshot must be an object');
910
+ }
911
+ const tokens: Record<string, string> = {};
912
+ for (const [playerId, token] of Object.entries(captured)) {
913
+ if (playerId.trim().length === 0) {
914
+ throw new TypeError(
915
+ 'player session store snapshot player ids must be non-empty',
916
+ );
917
+ }
918
+ if (typeof token !== 'string' || token.trim().length === 0) {
919
+ throw new TypeError(
920
+ `player session store snapshot token for ${playerId} must be a non-empty string`,
921
+ );
922
+ }
923
+ tokens[playerId] = token;
924
+ }
925
+ return tokens;
926
+ };
927
+ const restorePlayerResumeTokens = (
928
+ tokens: Readonly<Record<string, string>>,
929
+ ): void => {
930
+ const session = requireSessionIdentity();
931
+ if (session.playerSessions) {
932
+ session.playerSessions.restore(tokens);
933
+ return;
934
+ }
935
+ playerResumeTokens.clear();
936
+ for (const [playerId, token] of Object.entries(tokens)) {
937
+ playerResumeTokens.set(playerId, token);
938
+ }
939
+ };
940
+ const currentState = (
941
+ pendingCall: PlaybookPendingCall | undefined =
942
+ nestedBridge.getPendingCall(),
943
+ ): PlaybookState => {
963
944
  const live = actor;
964
945
  if (!live) {
965
- throw new Error('discuss runtime: actor is not initialized');
946
+ throw new Error('decide runtime: actor is not initialized');
966
947
  }
967
- return normalizePlaybookSnapshot(live.getSnapshot());
948
+ return normalizePlaybookSnapshot(live.getSnapshot(), {
949
+ pendingCall,
950
+ });
968
951
  };
969
952
  const stateIdentity = (state: PlaybookState): { stateId?: string } => {
970
953
  return state.stateId === undefined ? {} : { stateId: state.stateId };
@@ -1070,7 +1053,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1070
1053
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
1071
1054
  };
1072
1055
  const queued = await judgeQueue.add(async () => {
1073
- // Keep the complete queue task pending until an active host promise
1056
+ // Keep the complete queue task pending until an active coder promise
1074
1057
  // settles. PQueue's signal option may reject add() while that task is
1075
1058
  // still running, which would let the turn drain race a late finish.
1076
1059
  signal.throwIfAborted();
@@ -1142,10 +1125,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1142
1125
  `resolved player "${playerId}" already has an in-flight call`,
1143
1126
  );
1144
1127
  }
1145
- inFlightPlayerIds.add(playerId);
1146
1128
  const prompt = composePlayerPrompt(input);
1147
- const resume: PlayerCallOptions['resume'] =
1148
- playerResumeTokens.get(playerId) ?? false;
1129
+ let resume: PlayerCallOptions['resume'];
1130
+ try {
1131
+ signal.throwIfAborted();
1132
+ resume = selectPlayerResume(playerId);
1133
+ } catch (error) {
1134
+ latchControlPlaneError(error, signal);
1135
+ throw error;
1136
+ }
1149
1137
  const callId = `player-${++playerCallSequence}`;
1150
1138
  const identity = {
1151
1139
  purpose: 'captain',
@@ -1167,6 +1155,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1167
1155
  },
1168
1156
  { turnId: currentTurnId, callId },
1169
1157
  );
1158
+ inFlightPlayerIds.add(playerId);
1170
1159
  try {
1171
1160
  await emitCallStarted(
1172
1161
  'player.call.started',
@@ -1183,13 +1172,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1183
1172
  requirePorts().callPlayer(playerId, prompt, signal, { resume }),
1184
1173
  );
1185
1174
  rawResult = await boundary;
1186
- // An XState sibling cancellation does not cancel an arbitrary host
1175
+ // An XState sibling cancellation does not cancel an arbitrary coder
1187
1176
  // promise. Re-check before a late resolution can mutate continuity or
1188
1177
  // masquerade as a successful boundary finish.
1189
1178
  signal.throwIfAborted();
1190
1179
  } catch (error) {
1191
1180
  // A rejected call produced no authoritative result, so the previous
1192
- // token remains untouched. This also covers a host promise that
1181
+ // token remains untouched. This also covers a coder promise that
1193
1182
  // resolves after its invocation signal was cancelled.
1194
1183
  latchControlPlaneError(error, signal);
1195
1184
  try {
@@ -1208,20 +1197,29 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1208
1197
  try {
1209
1198
  await emitFailure(error);
1210
1199
  } catch {
1211
- // The malformed host result remains authoritative.
1200
+ // The malformed coder result remains authoritative.
1212
1201
  }
1213
1202
  throw error;
1214
1203
  }
1215
1204
 
1216
1205
  // The resolved result is authoritative even on aborted/error status.
1217
1206
  // Update continuation state before interpreting that status.
1218
- if (
1219
- typeof result.resumeToken === 'string' &&
1220
- result.resumeToken.trim().length > 0
1221
- ) {
1222
- playerResumeTokens.set(playerId, result.resumeToken);
1223
- } else {
1224
- playerResumeTokens.delete(playerId);
1207
+ try {
1208
+ updatePlayerResume(
1209
+ playerId,
1210
+ typeof result.resumeToken === 'string' &&
1211
+ result.resumeToken.trim().length > 0
1212
+ ? result.resumeToken
1213
+ : undefined,
1214
+ );
1215
+ } catch (error) {
1216
+ latchControlPlaneError(error, signal);
1217
+ try {
1218
+ await emitFailure(error);
1219
+ } catch {
1220
+ // The continuation-store failure remains authoritative.
1221
+ }
1222
+ throw error;
1225
1223
  }
1226
1224
 
1227
1225
  // Keep this finish outside the boundary catch. A trace sink can record
@@ -1309,6 +1307,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1309
1307
  input.stateId,
1310
1308
  ),
1311
1309
  input,
1310
+ finalText,
1312
1311
  );
1313
1312
  } catch (error) {
1314
1313
  latchControlPlaneError(error, combined);
@@ -1317,89 +1316,162 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1317
1316
  },
1318
1317
  );
1319
1318
 
1320
- const providedMachine = discussMachine.provide({ actors: { player } });
1319
+ nestedBridge = createNestedPlaybookBridge<PlaybookInput>({
1320
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
1321
+ getBoundarySignal: () => currentSignal,
1322
+ callPlaybook: (request, signal) =>
1323
+ trackBoundaryCall(
1324
+ Promise.resolve(requirePorts().callPlaybook(request, signal)),
1325
+ ),
1326
+ emitStarted: async (event) => {
1327
+ playbookCallTurnIds.set(event.callId, currentTurnId);
1328
+ await emitTrace(
1329
+ 'playbook.call.started',
1330
+ {
1331
+ stateId: event.stateId,
1332
+ playbookId: event.playbookId,
1333
+ text: event.text,
1334
+ },
1335
+ {
1336
+ ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
1337
+ callId: event.callId,
1338
+ },
1339
+ );
1340
+ },
1341
+ emitFinished: async (event) => {
1342
+ const turnId = playbookCallTurnIds.get(event.callId);
1343
+ try {
1344
+ await emitTrace(
1345
+ 'playbook.call.finished',
1346
+ {
1347
+ stateId: event.stateId,
1348
+ playbookId: event.playbookId,
1349
+ text: event.text,
1350
+ result: event.result,
1351
+ },
1352
+ {
1353
+ ...(turnId === undefined ? {} : { turnId }),
1354
+ callId: event.callId,
1355
+ },
1356
+ );
1357
+ } finally {
1358
+ playbookCallTurnIds.delete(event.callId);
1359
+ }
1360
+ },
1361
+ drain: flush,
1362
+ bindResumeSignal: (signal) => {
1363
+ currentSignal = signal;
1364
+ },
1365
+ onControlPlaneError: (error) => {
1366
+ const signal = currentSignal;
1367
+ if (!signal || !isAbortFailure(error, signal)) {
1368
+ controlPlaneError ??= error;
1369
+ }
1370
+ },
1371
+ onBackgroundError: (error) => {
1372
+ collectFailure(emissionFailures, error);
1373
+ },
1374
+ });
1375
+
1376
+ const providedMachine = decideMachine.provide({
1377
+ actors: { player, playbook: nestedBridge.actorLogic },
1378
+ });
1321
1379
 
1322
1380
  const inspect = (event: InspectionEvent): void => {
1323
1381
  if (event.type !== '@xstate.snapshot') return;
1324
1382
  if (actor === undefined || event.actorRef !== actor) return;
1325
1383
  if (suppressInspectionEmissions) return;
1326
- const snapshot = event.snapshot as SnapshotFrom<typeof discussMachine>;
1327
- const state = normalizePlaybookSnapshot(snapshot);
1328
- const prior = previousState;
1329
- previousState = state;
1330
-
1331
- const runtimePorts = ports;
1332
- if (!runtimePorts) return;
1333
- const context = snapshot.context as Record<string, unknown>;
1334
- const fsmPayload = telemetryPayload(prior, state, event.event, context);
1335
- const describedFsmPayload = snapshotJsonValue(
1336
- fsmPayload,
1337
- 'described FSM telemetry',
1338
- );
1384
+ try {
1385
+ const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
1386
+ const state = normalizePlaybookSnapshot(snapshot);
1387
+ const prior = previousState ?? state;
1388
+ const context = snapshot.context as unknown as Record<string, unknown>;
1389
+ const fsmPayload = telemetryPayload(prior, state, event.event, context);
1390
+ const describedFsmPayload = snapshotJsonValue(
1391
+ fsmPayload,
1392
+ 'described FSM telemetry',
1393
+ );
1339
1394
 
1340
- void enqueueTracedEmission(
1341
- 'fsm.transition',
1342
- fsmPayload,
1343
- { turnId: currentTurnId },
1344
- (emissionPorts) =>
1345
- emissionPorts.emitTelemetry({
1346
- topic: TELEMETRY_TOPIC,
1347
- payload: describedFsmPayload,
1348
- }),
1349
- ).catch(() => undefined);
1350
-
1351
- const priorIds = new Set(prior?.activeStateIds ?? []);
1352
- const pendingQuestions = pendingQuestionsFromContext(context);
1353
- const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1354
- STATUS_STATE_IDS.has(stateId),
1355
- );
1356
- const scheduleStatus = (
1357
- message: string,
1358
- stateId: string,
1359
- data?: JsonValue,
1360
- ): void => {
1361
- const tracePayload = {
1362
- ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1363
- message,
1364
- state,
1365
- ...(data !== undefined ? { data } : {}),
1366
- };
1367
- assertJsonSafe(tracePayload);
1368
1395
  void enqueueTracedEmission(
1369
- 'status.emitted',
1370
- tracePayload,
1396
+ 'fsm.transition',
1397
+ fsmPayload,
1371
1398
  { turnId: currentTurnId },
1372
- (emissionPorts) => emissionPorts.emitStatus(message, data),
1399
+ (emissionPorts) =>
1400
+ emissionPorts.emitTelemetry({
1401
+ topic: TELEMETRY_TOPIC,
1402
+ payload: describedFsmPayload,
1403
+ }),
1373
1404
  ).catch(() => undefined);
1374
- };
1375
1405
 
1376
- for (const activeStateId of state.activeStateIds) {
1377
- if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
1378
- continue;
1379
- }
1380
- if (WAIT_STATE_IDS.has(activeStateId)) {
1381
- const pending = questionForWaitState(activeStateId, pendingQuestions);
1382
- if (pending) {
1383
- scheduleStatus(
1384
- `${pending.player} asks: ${pending.question}`,
1385
- activeStateId,
1386
- );
1387
- scheduleStatus(
1388
- `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1406
+ const priorIds = new Set(previousState?.activeStateIds ?? []);
1407
+ previousState = state;
1408
+ const pendingQuestions = pendingQuestionsFromContext(context);
1409
+ const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1410
+ STATUS_STATE_IDS.has(stateId),
1411
+ );
1412
+ const scheduleStatus = (
1413
+ message: string,
1414
+ stateId: string,
1415
+ data?: JsonValue,
1416
+ ): void => {
1417
+ const tracePayload = {
1418
+ ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1419
+ message,
1420
+ state,
1421
+ ...(data !== undefined ? { data } : {}),
1422
+ };
1423
+ assertJsonSafe(tracePayload);
1424
+ void enqueueTracedEmission(
1425
+ 'status.emitted',
1426
+ tracePayload,
1427
+ { turnId: currentTurnId },
1428
+ (emissionPorts) => emissionPorts.emitStatus(message, data),
1429
+ ).catch(() => undefined);
1430
+ };
1431
+
1432
+ for (const activeStateId of state.activeStateIds) {
1433
+ if (
1434
+ priorIds.has(activeStateId) ||
1435
+ !STATUS_STATE_IDS.has(activeStateId)
1436
+ ) {
1437
+ continue;
1438
+ }
1439
+ if (WAIT_STATE_IDS.has(activeStateId)) {
1440
+ const pending = questionForWaitState(
1389
1441
  activeStateId,
1442
+ pendingQuestions,
1390
1443
  );
1444
+ if (pending) {
1445
+ scheduleStatus(
1446
+ `${pending.player} asks: ${pending.question}`,
1447
+ activeStateId,
1448
+ );
1449
+ scheduleStatus(
1450
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1451
+ activeStateId,
1452
+ );
1453
+ }
1454
+ continue;
1391
1455
  }
1392
- continue;
1456
+ const lastError =
1457
+ activeStateId === 'failed'
1458
+ ? normalizeErrorCompact(context.lastError)
1459
+ : undefined;
1460
+ const description = STATE_DESCRIPTIONS[activeStateId];
1461
+ if (description === undefined) continue;
1462
+ const playerState = PLAYER_STATES.find(
1463
+ (candidate) => candidate.stateId === activeStateId,
1464
+ );
1465
+ scheduleStatus(
1466
+ playerState === undefined
1467
+ ? '◆ workflow failed; awaiting Boss recovery.'
1468
+ : `⤷ ${playerState.player}: ${description}`,
1469
+ activeStateId,
1470
+ lastError === undefined ? undefined : { lastError },
1471
+ );
1393
1472
  }
1394
- const lastError =
1395
- activeStateId === 'failed'
1396
- ? normalizeErrorCompact(context.lastError)
1397
- : undefined;
1398
- scheduleStatus(
1399
- STATE_DESCRIPTIONS[activeStateId] ?? activeStateId,
1400
- activeStateId,
1401
- lastError === undefined ? undefined : { lastError },
1402
- );
1473
+ } catch (error) {
1474
+ latchInspectionError(error);
1403
1475
  }
1404
1476
  };
1405
1477
 
@@ -1413,7 +1485,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1413
1485
  ? {}
1414
1486
  : {
1415
1487
  snapshot: machineSnapshot as unknown as SnapshotFrom<
1416
- typeof discussMachine
1488
+ typeof decideMachine
1417
1489
  >,
1418
1490
  }),
1419
1491
  inspect,
@@ -1442,20 +1514,31 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1442
1514
 
1443
1515
  const driveToQuiescence = async (): Promise<void> => {
1444
1516
  const live = actor;
1445
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1446
- await waitForPlaybookQuiescence(live);
1517
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1518
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
1447
1519
  };
1448
1520
 
1449
1521
  const classify = async (
1450
1522
  text: string,
1451
1523
  signal: AbortSignal,
1452
- ): Promise<DiscussEvent | null> => {
1524
+ ): Promise<DecideEvent | { type: 'NO_ACTION' } | null> => {
1453
1525
  const live = actor;
1454
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1455
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof discussMachine>;
1456
- const context = snapshot.context as Record<string, unknown>;
1457
- const state = normalizePlaybookSnapshot(snapshot);
1526
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1527
+ const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
1528
+ const context = snapshot.context as unknown as Record<string, unknown>;
1529
+ const state = normalizePlaybookSnapshot(snapshot, {
1530
+ pendingCall: nestedBridge.getPendingCall(),
1531
+ });
1458
1532
  const pendingQuestions = pendingQuestionsFromContext(context);
1533
+ if (
1534
+ pendingQuestions.length === 0 &&
1535
+ (snapshot.status === 'done' ||
1536
+ state.activeStateIds.includes('ready') ||
1537
+ state.activeStateIds.includes('failed'))
1538
+ ) {
1539
+ return { type: 'START_DECIDE', callerTopic: text };
1540
+ }
1541
+ if (pendingQuestions.length === 0) return null;
1459
1542
  const prompt = buildClassifierPrompt(text, {
1460
1543
  state,
1461
1544
  pendingQuestions,
@@ -1468,16 +1551,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1468
1551
  );
1469
1552
  return parseClassification(
1470
1553
  raw,
1554
+ text,
1471
1555
  pendingQuestions.map(({ questionId }) => questionId),
1472
1556
  );
1473
1557
  };
1474
1558
 
1475
1559
  const resultForSnapshot = (signal?: AbortSignal): PlaybookRunResult => {
1476
1560
  const live = actor;
1477
- if (!live) throw new Error('discuss runtime: actor is not initialized');
1478
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof discussMachine>;
1479
- const state = normalizePlaybookSnapshot(snapshot);
1480
- const context = snapshot.context as Record<string, unknown>;
1561
+ if (!live) throw new Error('decide runtime: actor is not initialized');
1562
+ const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
1563
+ const pendingCall = nestedBridge.getPendingCall();
1564
+ const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1565
+ const context = snapshot.context as unknown as Record<string, unknown>;
1481
1566
  if (signal?.aborted) {
1482
1567
  return {
1483
1568
  outcome: 'aborted',
@@ -1504,7 +1589,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1504
1589
  if (snapshot.status === 'error') {
1505
1590
  throw (
1506
1591
  (snapshot as { error?: unknown }).error ??
1507
- new Error('discuss runtime actor entered error status')
1592
+ new Error('decide runtime actor entered error status')
1508
1593
  );
1509
1594
  }
1510
1595
  if (state.activeStateIds.includes('failed')) {
@@ -1515,6 +1600,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1515
1600
  ...(error === undefined ? {} : { error }),
1516
1601
  };
1517
1602
  }
1603
+ if (pendingCall) {
1604
+ return { outcome: 'suspended', state, pendingCall };
1605
+ }
1518
1606
  return { outcome: 'quiescent', state };
1519
1607
  };
1520
1608
 
@@ -1524,9 +1612,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1524
1612
  // The caller rethrows its original failure. A restore failure skips
1525
1613
  // the disposal trace — the parked session was never re-bound in this
1526
1614
  // process, so its persisted snapshot stays authoritative (DR-014 §2).
1527
- const cleanupFailedStart = async (options: {
1528
- emitDisposal: boolean;
1529
- }): Promise<void> => {
1615
+ const cleanupFailedStart = async (
1616
+ cause: unknown,
1617
+ options: { emitDisposal: boolean },
1618
+ ): Promise<void> => {
1530
1619
  let finalState: PlaybookState | undefined;
1531
1620
  if (options.emitDisposal && actor) {
1532
1621
  try {
@@ -1540,6 +1629,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1540
1629
  } catch {
1541
1630
  // Preserve the original startup failure.
1542
1631
  }
1632
+ try {
1633
+ await nestedBridge.abortPending(cause);
1634
+ } catch {
1635
+ // Preserve the original startup failure.
1636
+ }
1543
1637
  try {
1544
1638
  await judgeQueue.onIdle();
1545
1639
  await drainBoundaryCallsAndEmissions();
@@ -1577,6 +1671,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1577
1671
  turnSequence = 0;
1578
1672
  judgeCallSequence = 0;
1579
1673
  playerCallSequence = 0;
1674
+ playbookCallSequence = 0;
1675
+ playbookCallTurnIds.clear();
1580
1676
  lifecycleStarted = false;
1581
1677
  };
1582
1678
 
@@ -1589,7 +1685,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1589
1685
  disposalPromise !== undefined
1590
1686
  ) {
1591
1687
  throw new Error(
1592
- 'discuss runtime: init(session) may only be called once',
1688
+ 'decide runtime: init(session) may only be called once',
1593
1689
  );
1594
1690
  }
1595
1691
  const identity = snapshotPlaybookSession(session);
@@ -1612,7 +1708,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1612
1708
  actor?.start();
1613
1709
  await flush();
1614
1710
  } catch (error) {
1615
- await cleanupFailedStart({ emitDisposal: true });
1711
+ await cleanupFailedStart(error, { emitDisposal: true });
1616
1712
  throw error;
1617
1713
  } finally {
1618
1714
  finishInitialization();
@@ -1620,11 +1716,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1620
1716
  }
1621
1717
  },
1622
1718
 
1623
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1719
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1720
+ // session, including one already-started suspended REVIEW call.
1624
1721
  // Defined only at a safe capture point — initialized, not disposing
1625
- // or disposed, no active public boundary, and the actor quiescent
1626
- // with status `active`. DISCUSS never opens nested playbook calls,
1627
- // so no pending-call guard applies.
1722
+ // or disposed, no active public boundary, and the actor quiescent with
1723
+ // status `active`.
1628
1724
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1629
1725
  if (
1630
1726
  !actor ||
@@ -1637,25 +1733,56 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1637
1733
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1638
1734
  return undefined;
1639
1735
  }
1736
+ const pendingCall = nestedBridge.getPendingCall();
1737
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
1738
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
1739
+ return undefined;
1740
+ }
1741
+ if (
1742
+ pendingCall !== undefined &&
1743
+ bridgeSuspendedCall !== undefined &&
1744
+ (pendingCall.callId !== bridgeSuspendedCall.callId ||
1745
+ pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
1746
+ pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)
1747
+ ) {
1748
+ return undefined;
1749
+ }
1750
+ let suspendedCall: typeof bridgeSuspendedCall;
1751
+ if (bridgeSuspendedCall !== undefined) {
1752
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
1753
+ return undefined;
1754
+ }
1755
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
1756
+ if (
1757
+ bridgeSuspendedCall.turnId !== undefined &&
1758
+ bridgeSuspendedCall.turnId !== turnId
1759
+ ) {
1760
+ return undefined;
1761
+ }
1762
+ suspendedCall = {
1763
+ ...bridgeSuspendedCall,
1764
+ ...(turnId === undefined ? {} : { turnId }),
1765
+ };
1766
+ }
1640
1767
  const state = currentState();
1641
1768
  if (state.status !== 'active' || !state.quiescent) return undefined;
1642
1769
  const machine = detachPersistedMachineSnapshot(
1643
1770
  actor.getPersistedSnapshot(),
1644
1771
  );
1645
1772
  const context = (
1646
- actor.getSnapshot() as SnapshotFrom<typeof discussMachine>
1647
- ).context as Record<string, unknown>;
1773
+ actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1774
+ ).context as unknown as Record<string, unknown>;
1648
1775
  return {
1649
- schemaVersion: 1,
1776
+ schemaVersion: 2,
1650
1777
  playbookId: sessionIdentity.playbookId,
1651
1778
  machine,
1652
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1779
+ playerResumeTokens: snapshotPlayerResumeTokens(),
1653
1780
  sequences: {
1654
1781
  trace: traceSequence,
1655
1782
  turn: turnSequence,
1656
1783
  judgeCall: judgeCallSequence,
1657
1784
  playerCall: playerCallSequence,
1658
- playbookCall: 0,
1785
+ playbookCall: playbookCallSequence,
1659
1786
  },
1660
1787
  state,
1661
1788
  pendingBossQuestions: pendingQuestionsFromContext(context).map(
@@ -1666,6 +1793,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1666
1793
  sourceItem: pending.sourceItem,
1667
1794
  }),
1668
1795
  ),
1796
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
1669
1797
  };
1670
1798
  },
1671
1799
 
@@ -1685,14 +1813,19 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1685
1813
  disposalPromise !== undefined
1686
1814
  ) {
1687
1815
  throw new Error(
1688
- 'discuss runtime: restore(session, snapshot) may only be called once',
1816
+ 'decide runtime: restore(session, snapshot) may only be called once',
1689
1817
  );
1690
1818
  }
1691
1819
  const identity = snapshotPlaybookSession(session);
1692
1820
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
1693
1821
  snapshot,
1694
1822
  identity.playbookId,
1823
+ { allowSuspendedCall: true },
1695
1824
  );
1825
+ const suspendedCall =
1826
+ boundSnapshot.schemaVersion === 2
1827
+ ? boundSnapshot.suspendedCall
1828
+ : undefined;
1696
1829
  let finishInitialization!: () => void;
1697
1830
  const initialization = new Promise<void>((resolve) => {
1698
1831
  finishInitialization = resolve;
@@ -1701,32 +1834,61 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1701
1834
  lifecycleStarted = true;
1702
1835
  ports = identity.ports;
1703
1836
  sessionIdentity = identity;
1837
+ let priorExternalPlayerTokens:
1838
+ | Readonly<Record<string, string>>
1839
+ | undefined;
1704
1840
  try {
1705
1841
  traceSequence = boundSnapshot.sequences.trace;
1706
1842
  turnSequence = boundSnapshot.sequences.turn;
1707
1843
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1708
1844
  playerCallSequence = boundSnapshot.sequences.playerCall;
1709
- playerResumeTokens.clear();
1710
- for (const [playerId, token] of Object.entries(
1711
- boundSnapshot.playerResumeTokens,
1712
- )) {
1713
- playerResumeTokens.set(playerId, token);
1845
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1846
+ if (identity.playerSessions) {
1847
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1848
+ }
1849
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1850
+ nestedBridge.prepareRestore(suspendedCall);
1851
+ if (suspendedCall !== undefined) {
1852
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1714
1853
  }
1715
1854
  suppressInspectionEmissions = true;
1716
1855
  createRuntimeActor(boundSnapshot.machine);
1717
1856
  actor?.start();
1718
- const restoredState = currentState();
1857
+ const restoredState = currentState(suspendedCall);
1719
1858
  if (restoredState.status !== 'active') {
1720
1859
  throw new Error(
1721
- `discuss runtime: restored actor status is ${restoredState.status}, expected active`,
1860
+ `decide runtime: restored actor status is ${restoredState.status}, expected active`,
1861
+ );
1862
+ }
1863
+ if (
1864
+ stableJson(restoredState, 'restored runtime state') !==
1865
+ stableJson(boundSnapshot.state, 'runtime snapshot state')
1866
+ ) {
1867
+ throw new Error(
1868
+ 'decide runtime: restored actor state does not match snapshot state',
1722
1869
  );
1723
1870
  }
1724
- suppressInspectionEmissions = false;
1725
1871
  previousState = restoredState;
1726
1872
  await flush();
1873
+ suppressInspectionEmissions = false;
1874
+ // Final fallible step: after this publication the authoritative
1875
+ // child has rejoined ordinary resume/abort ownership, so no later
1876
+ // restore validation may trigger failed-start rollback.
1877
+ nestedBridge.confirmRestore();
1727
1878
  } catch (error) {
1728
- await cleanupFailedStart({ emitDisposal: false });
1729
- throw error;
1879
+ let failure = error;
1880
+ if (priorExternalPlayerTokens !== undefined) {
1881
+ try {
1882
+ identity.playerSessions!.restore(priorExternalPlayerTokens);
1883
+ } catch (rollbackError) {
1884
+ failure = new AggregateError(
1885
+ [error, rollbackError],
1886
+ 'DECIDE restore and player continuation rollback failed',
1887
+ );
1888
+ }
1889
+ }
1890
+ await cleanupFailedStart(failure, { emitDisposal: false });
1891
+ throw failure;
1730
1892
  } finally {
1731
1893
  finishInitialization();
1732
1894
  if (initInFlight === initialization) initInFlight = undefined;
@@ -1738,16 +1900,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1738
1900
  signal: AbortSignal;
1739
1901
  }): Promise<PlaybookRunResult> {
1740
1902
  if (disposalPromise !== undefined) {
1741
- throw new Error('discuss runtime: runtime is disposing or disposed');
1903
+ throw new Error('decide runtime: runtime is disposing or disposed');
1742
1904
  }
1743
1905
  requirePorts();
1744
1906
  if (!actor) {
1745
1907
  throw new Error(
1746
- 'discuss runtime: init(session) must be called before handleBossInput',
1908
+ 'decide runtime: init(session) must be called before handleBossInput',
1747
1909
  );
1748
1910
  }
1749
- if (currentTurnId !== undefined) {
1750
- throw new Error('discuss runtime: another runtime turn is active');
1911
+ if (currentSignal !== undefined) {
1912
+ throw new Error('decide runtime: another runtime turn is active');
1751
1913
  }
1752
1914
 
1753
1915
  const turnId = ++turnSequence;
@@ -1768,6 +1930,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1768
1930
  const state = currentState();
1769
1931
  await emitBoundaryStatus('No playbook action classified.', state);
1770
1932
  result = { outcome: 'no-action', state };
1933
+ } else if (event.type === 'NO_ACTION') {
1934
+ result = { outcome: 'no-action', state: currentState() };
1771
1935
  } else {
1772
1936
  await emitBoundaryStatus(event.type, currentState());
1773
1937
  if (actor.getSnapshot().status === 'done') {
@@ -1852,36 +2016,77 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1852
2016
 
1853
2017
  if (failures.length === 1) throw failures[0];
1854
2018
  if (failures.length > 1) {
1855
- throw new AggregateError(failures, 'discuss runtime turn failed');
2019
+ throw new AggregateError(failures, 'decide runtime turn failed');
1856
2020
  }
1857
2021
  return result;
1858
2022
  },
1859
2023
 
1860
2024
  async resumePlaybookCall({
1861
2025
  callId,
2026
+ result: childResult,
2027
+ signal,
1862
2028
  }: {
1863
2029
  callId: string;
1864
2030
  result: PlaybookCallResult;
1865
2031
  signal: AbortSignal;
1866
2032
  }): Promise<PlaybookRunResult> {
1867
2033
  if (disposalPromise !== undefined) {
1868
- throw new Error('discuss runtime: runtime is disposing or disposed');
2034
+ throw new Error('decide runtime: runtime is disposing or disposed');
1869
2035
  }
1870
2036
  requirePorts();
1871
2037
  if (!actor) {
1872
2038
  throw new Error(
1873
- 'discuss runtime: init(session) must be called before resumePlaybookCall',
2039
+ 'decide runtime: init(session) must be called before resumePlaybookCall',
1874
2040
  );
1875
2041
  }
1876
- throw new Error(`unknown or stale playbook call id ${callId}`);
2042
+ if (currentSignal !== undefined) {
2043
+ throw new Error('decide runtime: another runtime turn is active');
2044
+ }
2045
+ currentTurnId = playbookCallTurnIds.get(callId);
2046
+ currentSignal = signal;
2047
+ controlPlaneError = undefined;
2048
+ let runResult: PlaybookRunResult | undefined;
2049
+ let operationError: unknown;
2050
+ try {
2051
+ await nestedBridge.resume({
2052
+ callId,
2053
+ result: childResult,
2054
+ signal,
2055
+ });
2056
+ } catch (error) {
2057
+ operationError = error;
2058
+ }
2059
+ try {
2060
+ await waitForPlaybookQuiescence(actor, {
2061
+ pendingCalls: nestedBridge,
2062
+ });
2063
+ runResult = resultForSnapshot(signal);
2064
+ } catch (error) {
2065
+ operationError ??= error;
2066
+ }
2067
+ let drainError: unknown;
2068
+ try {
2069
+ await drainBoundaryCallsAndEmissions();
2070
+ } catch (error) {
2071
+ drainError = error;
2072
+ }
2073
+ const failure = controlPlaneError ?? drainError ?? operationError;
2074
+ currentSignal = undefined;
2075
+ currentTurnId = undefined;
2076
+ controlPlaneError = undefined;
2077
+ if (failure !== undefined) throw failure;
2078
+ if (runResult === undefined) {
2079
+ throw new Error('decide runtime: playbook resume produced no result');
2080
+ }
2081
+ return runResult;
1877
2082
  },
1878
2083
 
1879
2084
  dispose(): Promise<void> {
1880
2085
  if (disposalPromise !== undefined) return disposalPromise;
1881
- if (currentTurnId !== undefined) {
2086
+ if (currentSignal !== undefined) {
1882
2087
  return Promise.reject(
1883
2088
  new Error(
1884
- 'discuss runtime: cannot dispose while a runtime turn is active',
2089
+ 'decide runtime: cannot dispose while a runtime turn is active',
1885
2090
  ),
1886
2091
  );
1887
2092
  }
@@ -1895,6 +2100,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1895
2100
  const finalState = currentState();
1896
2101
  const failures: unknown[] = [];
1897
2102
  stopActor();
2103
+ try {
2104
+ await nestedBridge.dispose();
2105
+ } catch (error) {
2106
+ collectFailure(failures, error);
2107
+ }
1898
2108
  try {
1899
2109
  await drainBoundaryCallsAndEmissions();
1900
2110
  } catch (error) {
@@ -1914,6 +2124,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1914
2124
  collectFailure(failures, error);
1915
2125
  } finally {
1916
2126
  playerResumeTokens.clear();
2127
+ playbookCallTurnIds.clear();
1917
2128
  inFlightPlayerIds.clear();
1918
2129
  activeBoundaryCalls.clear();
1919
2130
  activeEmissionCalls.clear();
@@ -1930,7 +2141,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1930
2141
  }
1931
2142
  if (failures.length === 1) throw failures[0];
1932
2143
  if (failures.length > 1) {
1933
- throw new AggregateError(failures, 'discuss runtime disposal failed');
2144
+ throw new AggregateError(failures, 'decide runtime disposal failed');
1934
2145
  }
1935
2146
  })();
1936
2147
  return disposalPromise;
@@ -1952,10 +2163,10 @@ export const _internal = {
1952
2163
  normalizeErrorCompact,
1953
2164
  normalizeErrorFull,
1954
2165
  DEFAULT_PLAYER_BINDING,
1955
- ALIAS_RESOLUTION,
1956
2166
  STATE_DESCRIPTIONS,
1957
- CAPTAIN_STATES,
1958
- CAPTAIN_STATE_IDS,
2167
+ PLAYER_STATES,
2168
+ PLAYER_STATE_IDS,
2169
+ VERBATIM_PAYLOAD_FIELDS,
1959
2170
  BOSS_INTERRUPT_TARGETS,
1960
2171
  CONTINUATION_PREAMBLE,
1961
2172
  TELEMETRY_TOPIC,