@sublang/playbook 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +11 -7
  2. package/docs/cli.md +38 -35
  3. package/docs/configuration.md +58 -15
  4. package/docs/embedding.md +24 -16
  5. package/package.json +40 -21
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
  8. package/reference/sdlc/code.md +55 -97
  9. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  10. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  11. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  12. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  13. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  14. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  15. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  16. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  17. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  18. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  19. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  20. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  21. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  22. package/reference/sdlc/code.playbook/playbook-captain.js +93 -15
  23. package/reference/sdlc/code.playbook/playbook-captain.ts +115 -19
  24. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  25. package/reference/sdlc/decide.md +54 -0
  26. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  27. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  29. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  30. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  31. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +471 -362
  32. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +575 -443
  33. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  34. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  35. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  36. package/reference/sdlc/review.md +81 -0
  37. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  38. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  39. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  40. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  41. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  42. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  43. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  44. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  45. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  46. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  47. package/slc/gears2fsm.md +13 -4
  48. package/slc/link.md +48 -2
  49. package/slc/text2gears.md +22 -2
  50. package/src/runtime.d.ts +7 -0
  51. package/src/runtime.ts +12 -0
  52. package/src/xstate-playbook-runtime.d.ts +9 -2
  53. package/src/xstate-playbook-runtime.js +255 -21
  54. package/src/xstate-playbook-runtime.ts +333 -28
  55. package/src/xstate-runtime.js +25 -0
  56. package/src/xstate-runtime.ts +51 -0
  57. package/reference/sdlc/discuss.md +0 -93
  58. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  59. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  60. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  61. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  62. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  63. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  64. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  65. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -1,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' },
71
+ const PLAYER_STATES = [
72
+ { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
112
73
  {
113
- stateId: 'commitInitialChanges',
114
- player: 'Committer',
115
- sourceItem: 'DISCUSS-14',
74
+ stateId: 'askReviewerProposal',
75
+ player: 'Reviewer',
76
+ sourceItem: 'DECIDE-2',
116
77
  },
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
- },
157
- {
158
- stateId: 'commitReviewedChanges',
159
- player: 'Committer',
160
- sourceItem: 'DISCUSS-15',
161
- },
162
- ];
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',
78
+ { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
180
79
  ];
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;
@@ -364,7 +259,7 @@ function dropTrailingComma(value) {
364
259
  }
365
260
  function buildClassifierPrompt(text, ctx) {
366
261
  const lines = [];
367
- lines.push('You are the Boss-input classifier for the discuss playbook.');
262
+ lines.push('You are the Boss-input classifier for the decide playbook.');
368
263
  lines.push('Classify the Boss message into exactly one FSM event, or into no event.');
369
264
  lines.push('');
370
265
  lines.push(`Current FSM state: ${JSON.stringify(ctx.state.value)}`);
@@ -372,84 +267,66 @@ function buildClassifierPrompt(text, ctx) {
372
267
  if (ctx.pendingQuestions.length > 0) {
373
268
  lines.push('Pending Boss questions:');
374
269
  for (const pending of ctx.pendingQuestions) {
375
- lines.push(`- ${pending.questionId} (${pending.player}, ${pending.sourceItem}): ${pending.question}`);
270
+ lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
376
271
  }
377
272
  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
273
  }
379
274
  lines.push('');
380
275
  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.');
276
+ lines.push(`- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`);
277
+ 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.');
278
+ lines.push('- NO_ACTION: no fields.');
385
279
  lines.push('');
386
280
  lines.push('Boss message:');
387
281
  lines.push(text);
388
282
  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.');
283
+ lines.push('Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.');
284
+ lines.push('Use NO_ACTION when no FSM action applies.');
391
285
  return lines.join('\n');
392
286
  }
393
- function parseClassification(raw, pendingQuestionIds = []) {
287
+ function parseClassification(raw, text, pendingQuestionIds = []) {
394
288
  const obj = extractJson(raw);
395
289
  if (!obj)
396
290
  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;
291
+ const exactKeys = (...keys) => {
292
+ const ownKeys = Reflect.ownKeys(obj);
293
+ return (ownKeys.length === keys.length &&
294
+ ownKeys.every((key) => typeof key === 'string' && keys.includes(key)));
295
+ };
296
+ const eventType = obj.type;
297
+ if (eventType === 'NO_ACTION') {
298
+ return exactKeys('type') ? { type: 'NO_ACTION' } : null;
427
299
  }
428
300
  if (eventType === 'BOSS_INTERRUPT') {
429
- if (typeof obj.targetId === 'string' &&
301
+ if (exactKeys('type', 'targetId') &&
302
+ typeof obj.targetId === 'string' &&
430
303
  BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)) {
431
- return { type: 'BOSS_INTERRUPT', targetId: obj.targetId };
304
+ return {
305
+ type: 'BOSS_INTERRUPT',
306
+ targetId: obj.targetId,
307
+ bossIntent: text,
308
+ };
432
309
  }
433
310
  return null;
434
311
  }
435
312
  if (eventType === 'BOSS_REPLY') {
436
- if (typeof obj.answer !== 'string' || pendingQuestionIds.length === 0) {
313
+ if (pendingQuestionIds.length === 0)
437
314
  return null;
438
- }
439
315
  if (typeof obj.questionId === 'string') {
440
- return pendingQuestionIds.includes(obj.questionId)
316
+ return exactKeys('type', 'questionId') &&
317
+ pendingQuestionIds.includes(obj.questionId)
441
318
  ? {
442
319
  type: 'BOSS_REPLY',
443
320
  questionId: obj.questionId,
444
- answer: obj.answer,
321
+ answer: text,
445
322
  }
446
323
  : null;
447
324
  }
448
- return pendingQuestionIds.length === 1
325
+ return exactKeys('type') && pendingQuestionIds.length === 1
449
326
  ? {
450
327
  type: 'BOSS_REPLY',
451
328
  questionId: pendingQuestionIds[0],
452
- answer: obj.answer,
329
+ answer: text,
453
330
  }
454
331
  : null;
455
332
  }
@@ -473,11 +350,22 @@ function buildAdjudicatorPrompt(input, playerOutput) {
473
350
  for (const [guard, description] of Object.entries(input.result)) {
474
351
  lines.push(`- ${guard}: ${description}`);
475
352
  }
353
+ const runtimeOwnedFields = new Set();
354
+ for (const description of Object.values(input.result)) {
355
+ for (const field of requiredFieldsFor(description)) {
356
+ if (VERBATIM_PAYLOAD_FIELDS.has(field))
357
+ runtimeOwnedFields.add(field);
358
+ }
359
+ }
360
+ if (runtimeOwnedFields.size > 0) {
361
+ lines.push('');
362
+ lines.push(`The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`);
363
+ }
476
364
  lines.push('');
477
365
  lines.push('Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.');
478
366
  return lines.join('\n');
479
367
  }
480
- function parseAdjudication(raw, input) {
368
+ function parseAdjudication(raw, input, finalText) {
481
369
  const obj = extractJson(raw);
482
370
  if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
483
371
  throw new Error('adjudicator returned empty or malformed JSON');
@@ -486,25 +374,26 @@ function parseAdjudication(raw, input) {
486
374
  if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
487
375
  throw new Error(`adjudicator returned undeclared guard "${guard}" for ${input.sourceItem}`);
488
376
  }
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];
377
+ const requiredFields = requiredFieldsFor(input.result[guard]);
378
+ const allowedFields = new Set(['guard', ...requiredFields]);
379
+ for (const key of Reflect.ownKeys(obj)) {
380
+ if (typeof key !== 'string' || !allowedFields.has(key)) {
381
+ throw new Error(`adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`);
382
+ }
383
+ }
384
+ const output = { guard };
385
+ for (const field of requiredFields) {
386
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
387
+ output[field] = finalText;
388
+ continue;
389
+ }
390
+ const value = obj[field];
498
391
  if (value === undefined ||
499
392
  value === null ||
500
393
  (typeof value === 'string' && value.trim() === '')) {
501
394
  throw new Error(`adjudicator response for guard "${guard}" missing required field "${field}"`);
502
395
  }
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}"`);
396
+ output[field] = value;
508
397
  }
509
398
  return output;
510
399
  }
@@ -528,11 +417,7 @@ function isEmptyFinalText(finalText) {
528
417
  return finalText === undefined || finalText.trim().length === 0;
529
418
  }
530
419
  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';
420
+ return signal.aborted && Object.is(error, signal.reason);
536
421
  }
537
422
  function pendingQuestionsFromContext(context) {
538
423
  const pending = context.pendingBossQuestions;
@@ -559,17 +444,15 @@ function pendingQuestionsFromContext(context) {
559
444
  return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
560
445
  }
561
446
  const WAIT_STATE_RESUME_IDS = {
562
- waitHostInitialReply: 'askHostInitial',
563
- waitParticipantInitialReply: 'askParticipantInitial',
564
- waitHostReconciliationReply: 'hostInitialRound',
565
- waitParticipantReconciliationReply: 'participantInitialRound',
447
+ waitCoderProposalReply: 'askCoderProposal',
448
+ waitReviewerProposalReply: 'askReviewerProposal',
566
449
  };
567
450
  const WAIT_STATE_IDS = new Set([
568
451
  ...Object.keys(WAIT_STATE_RESUME_IDS),
569
452
  'awaitBossReply',
570
453
  ]);
571
454
  const STATUS_STATE_IDS = new Set([
572
- ...CAPTAIN_STATE_IDS,
455
+ ...PLAYER_STATE_IDS,
573
456
  ...WAIT_STATE_IDS,
574
457
  'failed',
575
458
  ]);
@@ -582,38 +465,44 @@ function questionForWaitState(stateId, pendingQuestions) {
582
465
  ? pendingQuestions[0]
583
466
  : undefined;
584
467
  }
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);
468
+ const TRANSITION_EVENT_FIELDS = [
469
+ 'callerTopic',
470
+ 'targetId',
471
+ 'bossIntent',
472
+ 'questionId',
473
+ 'answer',
474
+ ];
475
+ function normalizedTransitionEvent(event) {
476
+ if (event === null || typeof event !== 'object') {
477
+ return snapshotJsonValue(event ?? null, 'FSM event');
478
+ }
479
+ const source = event;
480
+ const descriptor = {};
481
+ if (typeof source.type === 'string')
482
+ descriptor.type = source.type;
483
+ for (const field of TRANSITION_EVENT_FIELDS) {
484
+ if (typeof source[field] === 'string') {
485
+ descriptor[field] = source[field];
486
+ }
487
+ }
488
+ if (source.output !== undefined) {
489
+ descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
592
490
  }
593
- if (event === null ||
594
- typeof event === 'string' ||
595
- typeof event === 'boolean' ||
596
- (typeof event === 'number' && Number.isFinite(event))) {
597
- return event;
491
+ if (source.error !== undefined) {
492
+ descriptor.error = snapshotJsonValue(normalizeError(source.error), 'FSM event error');
598
493
  }
599
- return String(event);
494
+ return snapshotJsonValue(descriptor, 'FSM event');
600
495
  }
601
496
  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
497
  const pendingBossQuestions = pendingQuestionsFromContext(context);
498
+ const prior = previousState ?? state;
609
499
  const payload = {
610
- from: previousState?.value ?? null,
500
+ from: prior.value,
611
501
  to: state.value,
612
- event: normalizedEventType(event),
613
- previousState: previousState ?? null,
502
+ event: normalizedTransitionEvent(event),
503
+ previousState: prior,
614
504
  state,
615
505
  ...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
616
- ...(eventError !== undefined ? { error: eventError } : {}),
617
506
  ...(state.activeStateIds.includes('failed')
618
507
  ? { lastError: normalizeErrorFull(context.lastError) ?? null }
619
508
  : {}),
@@ -622,15 +511,13 @@ function telemetryPayload(previousState, state, event, context) {
622
511
  return payload;
623
512
  }
624
513
  export const createPlaybookRuntime = (options) => {
625
- const boundOptions = snapshotDiscussRuntimeOptions(options);
514
+ const boundOptions = snapshotDecideRuntimeOptions(options);
626
515
  const binding = {
627
516
  ...DEFAULT_PLAYER_BINDING,
628
517
  ...(boundOptions.playerBinding ?? {}),
629
518
  };
630
519
  const fsmInput = {
631
- host: boundOptions.host,
632
- participant: boundOptions.participant,
633
- committer: boundOptions.committer,
520
+ coderLlm: boundOptions.coderLlm,
634
521
  };
635
522
  let ports;
636
523
  let sessionIdentity;
@@ -644,12 +531,15 @@ export const createPlaybookRuntime = (options) => {
644
531
  let turnSequence = 0;
645
532
  let judgeCallSequence = 0;
646
533
  let playerCallSequence = 0;
534
+ let playbookCallSequence = 0;
647
535
  let lifecycleStarted = false;
648
536
  let initInFlight;
649
537
  let disposed = false;
650
538
  let disposalPromise;
651
539
  let controlPlaneError;
540
+ let nestedBridge;
652
541
  const playerResumeTokens = new Map();
542
+ const playbookCallTurnIds = new Map();
653
543
  const inFlightPlayerIds = new Set();
654
544
  const activeBoundaryCalls = new Set();
655
545
  const activeEmissionCalls = new Set();
@@ -669,6 +559,14 @@ export const createPlaybookRuntime = (options) => {
669
559
  if (!isAbortFailure(error, signal))
670
560
  controlPlaneError ??= error;
671
561
  };
562
+ const latchInspectionError = (error) => {
563
+ if (currentSignal !== undefined) {
564
+ latchControlPlaneError(error, currentSignal);
565
+ }
566
+ else {
567
+ collectFailure(emissionFailures, error);
568
+ }
569
+ };
672
570
  const enqueue = (fn) => {
673
571
  const queued = emissionQueue.add(fn);
674
572
  activeEmissionCalls.add(queued);
@@ -696,7 +594,7 @@ export const createPlaybookRuntime = (options) => {
696
594
  emissionFailures = [];
697
595
  if (failures.length === 1)
698
596
  throw failures[0];
699
- throw new AggregateError(failures, 'discuss runtime emissions failed');
597
+ throw new AggregateError(failures, 'decide runtime emissions failed');
700
598
  };
701
599
  const drainBoundaryCallsAndEmissions = async () => {
702
600
  while (true) {
@@ -715,22 +613,78 @@ export const createPlaybookRuntime = (options) => {
715
613
  };
716
614
  const requirePorts = () => {
717
615
  if (!ports) {
718
- throw new Error('discuss runtime: init(session) must be called first');
616
+ throw new Error('decide runtime: init(session) must be called first');
719
617
  }
720
618
  return ports;
721
619
  };
722
620
  const requireSessionIdentity = () => {
723
621
  if (!sessionIdentity) {
724
- throw new Error('discuss runtime: init(session) must be called first');
622
+ throw new Error('decide runtime: init(session) must be called first');
725
623
  }
726
624
  return sessionIdentity;
727
625
  };
626
+ const selectPlayerResume = (playerId) => {
627
+ const session = requireSessionIdentity();
628
+ const selected = session.playerSessions
629
+ ? session.playerSessions.select(playerId)
630
+ : playerResumeTokens.get(playerId) ?? false;
631
+ if (selected !== false &&
632
+ (typeof selected !== 'string' || selected.trim().length === 0)) {
633
+ throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
634
+ }
635
+ return selected;
636
+ };
637
+ const updatePlayerResume = (playerId, resumeToken) => {
638
+ const session = requireSessionIdentity();
639
+ if (session.playerSessions) {
640
+ session.playerSessions.update(playerId, resumeToken);
641
+ }
642
+ else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
643
+ playerResumeTokens.set(playerId, resumeToken);
644
+ }
645
+ else {
646
+ playerResumeTokens.delete(playerId);
647
+ }
648
+ };
649
+ const snapshotPlayerResumeTokens = () => {
650
+ const session = requireSessionIdentity();
651
+ const captured = snapshotJsonValue(session.playerSessions
652
+ ? session.playerSessions.snapshot()
653
+ : Object.fromEntries(playerResumeTokens), 'player session store snapshot');
654
+ if (!isPlainObject(captured)) {
655
+ throw new TypeError('player session store snapshot must be an object');
656
+ }
657
+ const tokens = {};
658
+ for (const [playerId, token] of Object.entries(captured)) {
659
+ if (playerId.trim().length === 0) {
660
+ throw new TypeError('player session store snapshot player ids must be non-empty');
661
+ }
662
+ if (typeof token !== 'string' || token.trim().length === 0) {
663
+ throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
664
+ }
665
+ tokens[playerId] = token;
666
+ }
667
+ return tokens;
668
+ };
669
+ const restorePlayerResumeTokens = (tokens) => {
670
+ const session = requireSessionIdentity();
671
+ if (session.playerSessions) {
672
+ session.playerSessions.restore(tokens);
673
+ return;
674
+ }
675
+ playerResumeTokens.clear();
676
+ for (const [playerId, token] of Object.entries(tokens)) {
677
+ playerResumeTokens.set(playerId, token);
678
+ }
679
+ };
728
680
  const currentState = () => {
729
681
  const live = actor;
730
682
  if (!live) {
731
- throw new Error('discuss runtime: actor is not initialized');
683
+ throw new Error('decide runtime: actor is not initialized');
732
684
  }
733
- return normalizePlaybookSnapshot(live.getSnapshot());
685
+ return normalizePlaybookSnapshot(live.getSnapshot(), {
686
+ pendingCall: nestedBridge.getPendingCall(),
687
+ });
734
688
  };
735
689
  const stateIdentity = (state) => {
736
690
  return state.stateId === undefined ? {} : { stateId: state.stateId };
@@ -802,7 +756,7 @@ export const createPlaybookRuntime = (options) => {
802
756
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
803
757
  };
804
758
  const queued = await judgeQueue.add(async () => {
805
- // Keep the complete queue task pending until an active host promise
759
+ // Keep the complete queue task pending until an active coder promise
806
760
  // settles. PQueue's signal option may reject add() while that task is
807
761
  // still running, which would let the turn drain race a late finish.
808
762
  signal.throwIfAborted();
@@ -846,9 +800,16 @@ export const createPlaybookRuntime = (options) => {
846
800
  if (inFlightPlayerIds.has(playerId)) {
847
801
  throw new Error(`resolved player "${playerId}" already has an in-flight call`);
848
802
  }
849
- inFlightPlayerIds.add(playerId);
850
803
  const prompt = composePlayerPrompt(input);
851
- const resume = playerResumeTokens.get(playerId) ?? false;
804
+ let resume;
805
+ try {
806
+ signal.throwIfAborted();
807
+ resume = selectPlayerResume(playerId);
808
+ }
809
+ catch (error) {
810
+ latchControlPlaneError(error, signal);
811
+ throw error;
812
+ }
852
813
  const callId = `player-${++playerCallSequence}`;
853
814
  const identity = {
854
815
  purpose: 'captain',
@@ -865,6 +826,7 @@ export const createPlaybookRuntime = (options) => {
865
826
  message: String(error),
866
827
  },
867
828
  }, { turnId: currentTurnId, callId });
829
+ inFlightPlayerIds.add(playerId);
868
830
  try {
869
831
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
870
832
  let rawResult;
@@ -872,14 +834,14 @@ export const createPlaybookRuntime = (options) => {
872
834
  signal.throwIfAborted();
873
835
  const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
874
836
  rawResult = await boundary;
875
- // An XState sibling cancellation does not cancel an arbitrary host
837
+ // An XState sibling cancellation does not cancel an arbitrary coder
876
838
  // promise. Re-check before a late resolution can mutate continuity or
877
839
  // masquerade as a successful boundary finish.
878
840
  signal.throwIfAborted();
879
841
  }
880
842
  catch (error) {
881
843
  // A rejected call produced no authoritative result, so the previous
882
- // token remains untouched. This also covers a host promise that
844
+ // token remains untouched. This also covers a coder promise that
883
845
  // resolves after its invocation signal was cancelled.
884
846
  latchControlPlaneError(error, signal);
885
847
  try {
@@ -900,18 +862,27 @@ export const createPlaybookRuntime = (options) => {
900
862
  await emitFailure(error);
901
863
  }
902
864
  catch {
903
- // The malformed host result remains authoritative.
865
+ // The malformed coder result remains authoritative.
904
866
  }
905
867
  throw error;
906
868
  }
907
869
  // The resolved result is authoritative even on aborted/error status.
908
870
  // 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);
871
+ try {
872
+ updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
873
+ result.resumeToken.trim().length > 0
874
+ ? result.resumeToken
875
+ : undefined);
912
876
  }
913
- else {
914
- playerResumeTokens.delete(playerId);
877
+ catch (error) {
878
+ latchControlPlaneError(error, signal);
879
+ try {
880
+ await emitFailure(error);
881
+ }
882
+ catch {
883
+ // The continuation-store failure remains authoritative.
884
+ }
885
+ throw error;
915
886
  }
916
887
  // Keep this finish outside the boundary catch. A trace sink can record
917
888
  // the event and then reject; retrying from that catch would duplicate the
@@ -974,14 +945,62 @@ export const createPlaybookRuntime = (options) => {
974
945
  combined.throwIfAborted();
975
946
  try {
976
947
  const prompt = buildAdjudicatorPrompt(input, finalText);
977
- return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
948
+ return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
978
949
  }
979
950
  catch (error) {
980
951
  latchControlPlaneError(error, combined);
981
952
  throw error;
982
953
  }
983
954
  });
984
- const providedMachine = discussMachine.provide({ actors: { player } });
955
+ nestedBridge = createNestedPlaybookBridge({
956
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
957
+ getBoundarySignal: () => currentSignal,
958
+ callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
959
+ emitStarted: async (event) => {
960
+ playbookCallTurnIds.set(event.callId, currentTurnId);
961
+ await emitTrace('playbook.call.started', {
962
+ stateId: event.stateId,
963
+ playbookId: event.playbookId,
964
+ text: event.text,
965
+ }, {
966
+ ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
967
+ callId: event.callId,
968
+ });
969
+ },
970
+ emitFinished: async (event) => {
971
+ const turnId = playbookCallTurnIds.get(event.callId);
972
+ try {
973
+ await emitTrace('playbook.call.finished', {
974
+ stateId: event.stateId,
975
+ playbookId: event.playbookId,
976
+ text: event.text,
977
+ result: event.result,
978
+ }, {
979
+ ...(turnId === undefined ? {} : { turnId }),
980
+ callId: event.callId,
981
+ });
982
+ }
983
+ finally {
984
+ playbookCallTurnIds.delete(event.callId);
985
+ }
986
+ },
987
+ drain: flush,
988
+ bindResumeSignal: (signal) => {
989
+ currentSignal = signal;
990
+ },
991
+ onControlPlaneError: (error) => {
992
+ const signal = currentSignal;
993
+ if (!signal || !isAbortFailure(error, signal)) {
994
+ controlPlaneError ??= error;
995
+ }
996
+ },
997
+ onBackgroundError: (error) => {
998
+ collectFailure(emissionFailures, error);
999
+ },
1000
+ });
1001
+ const providedMachine = decideMachine.provide({
1002
+ actors: { player, playbook: nestedBridge.actorLogic },
1003
+ });
985
1004
  const inspect = (event) => {
986
1005
  if (event.type !== '@xstate.snapshot')
987
1006
  return;
@@ -989,49 +1008,58 @@ export const createPlaybookRuntime = (options) => {
989
1008
  return;
990
1009
  if (suppressInspectionEmissions)
991
1010
  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 } : {}),
1011
+ try {
1012
+ const snapshot = event.snapshot;
1013
+ const state = normalizePlaybookSnapshot(snapshot);
1014
+ const prior = previousState ?? state;
1015
+ const context = snapshot.context;
1016
+ const fsmPayload = telemetryPayload(prior, state, event.event, context);
1017
+ const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
1018
+ void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
1019
+ topic: TELEMETRY_TOPIC,
1020
+ payload: describedFsmPayload,
1021
+ })).catch(() => undefined);
1022
+ const priorIds = new Set(previousState?.activeStateIds ?? []);
1023
+ previousState = state;
1024
+ const pendingQuestions = pendingQuestionsFromContext(context);
1025
+ const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
1026
+ const scheduleStatus = (message, stateId, data) => {
1027
+ const tracePayload = {
1028
+ ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
1029
+ message,
1030
+ state,
1031
+ ...(data !== undefined ? { data } : {}),
1032
+ };
1033
+ assertJsonSafe(tracePayload);
1034
+ void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
1015
1035
  };
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);
1036
+ for (const activeStateId of state.activeStateIds) {
1037
+ if (priorIds.has(activeStateId) ||
1038
+ !STATUS_STATE_IDS.has(activeStateId)) {
1039
+ continue;
1028
1040
  }
1029
- continue;
1041
+ if (WAIT_STATE_IDS.has(activeStateId)) {
1042
+ const pending = questionForWaitState(activeStateId, pendingQuestions);
1043
+ if (pending) {
1044
+ scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1045
+ scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
1046
+ }
1047
+ continue;
1048
+ }
1049
+ const lastError = activeStateId === 'failed'
1050
+ ? normalizeErrorCompact(context.lastError)
1051
+ : undefined;
1052
+ const description = STATE_DESCRIPTIONS[activeStateId];
1053
+ if (description === undefined)
1054
+ continue;
1055
+ const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
1056
+ scheduleStatus(playerState === undefined
1057
+ ? '◆ workflow failed; awaiting Boss recovery.'
1058
+ : `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1030
1059
  }
1031
- const lastError = activeStateId === 'failed'
1032
- ? normalizeErrorCompact(context.lastError)
1033
- : undefined;
1034
- scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
1060
+ }
1061
+ catch (error) {
1062
+ latchInspectionError(error);
1035
1063
  }
1036
1064
  };
1037
1065
  const createRuntimeActor = (machineSnapshot) => {
@@ -1070,30 +1098,41 @@ export const createPlaybookRuntime = (options) => {
1070
1098
  const driveToQuiescence = async () => {
1071
1099
  const live = actor;
1072
1100
  if (!live)
1073
- throw new Error('discuss runtime: actor is not initialized');
1074
- await waitForPlaybookQuiescence(live);
1101
+ throw new Error('decide runtime: actor is not initialized');
1102
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
1075
1103
  };
1076
1104
  const classify = async (text, signal) => {
1077
1105
  const live = actor;
1078
1106
  if (!live)
1079
- throw new Error('discuss runtime: actor is not initialized');
1107
+ throw new Error('decide runtime: actor is not initialized');
1080
1108
  const snapshot = live.getSnapshot();
1081
1109
  const context = snapshot.context;
1082
- const state = normalizePlaybookSnapshot(snapshot);
1110
+ const state = normalizePlaybookSnapshot(snapshot, {
1111
+ pendingCall: nestedBridge.getPendingCall(),
1112
+ });
1083
1113
  const pendingQuestions = pendingQuestionsFromContext(context);
1114
+ if (pendingQuestions.length === 0 &&
1115
+ (snapshot.status === 'done' ||
1116
+ state.activeStateIds.includes('ready') ||
1117
+ state.activeStateIds.includes('failed'))) {
1118
+ return { type: 'START_DECIDE', callerTopic: text };
1119
+ }
1120
+ if (pendingQuestions.length === 0)
1121
+ return null;
1084
1122
  const prompt = buildClassifierPrompt(text, {
1085
1123
  state,
1086
1124
  pendingQuestions,
1087
1125
  });
1088
1126
  const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
1089
- return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
1127
+ return parseClassification(raw, text, pendingQuestions.map(({ questionId }) => questionId));
1090
1128
  };
1091
1129
  const resultForSnapshot = (signal) => {
1092
1130
  const live = actor;
1093
1131
  if (!live)
1094
- throw new Error('discuss runtime: actor is not initialized');
1132
+ throw new Error('decide runtime: actor is not initialized');
1095
1133
  const snapshot = live.getSnapshot();
1096
- const state = normalizePlaybookSnapshot(snapshot);
1134
+ const pendingCall = nestedBridge.getPendingCall();
1135
+ const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1097
1136
  const context = snapshot.context;
1098
1137
  if (signal?.aborted) {
1099
1138
  return {
@@ -1121,7 +1160,7 @@ export const createPlaybookRuntime = (options) => {
1121
1160
  }
1122
1161
  if (snapshot.status === 'error') {
1123
1162
  throw (snapshot.error ??
1124
- new Error('discuss runtime actor entered error status'));
1163
+ new Error('decide runtime actor entered error status'));
1125
1164
  }
1126
1165
  if (state.activeStateIds.includes('failed')) {
1127
1166
  const error = normalizeErrorFull(context.lastError);
@@ -1131,6 +1170,9 @@ export const createPlaybookRuntime = (options) => {
1131
1170
  ...(error === undefined ? {} : { error }),
1132
1171
  };
1133
1172
  }
1173
+ if (pendingCall) {
1174
+ return { outcome: 'suspended', state, pendingCall };
1175
+ }
1134
1176
  return { outcome: 'quiescent', state };
1135
1177
  };
1136
1178
  // Shared failed-start cleanup for init and restore: stop the actor,
@@ -1194,6 +1236,8 @@ export const createPlaybookRuntime = (options) => {
1194
1236
  turnSequence = 0;
1195
1237
  judgeCallSequence = 0;
1196
1238
  playerCallSequence = 0;
1239
+ playbookCallSequence = 0;
1240
+ playbookCallTurnIds.clear();
1197
1241
  lifecycleStarted = false;
1198
1242
  };
1199
1243
  return {
@@ -1202,7 +1246,7 @@ export const createPlaybookRuntime = (options) => {
1202
1246
  initInFlight !== undefined ||
1203
1247
  disposed ||
1204
1248
  disposalPromise !== undefined) {
1205
- throw new Error('discuss runtime: init(session) may only be called once');
1249
+ throw new Error('decide runtime: init(session) may only be called once');
1206
1250
  }
1207
1251
  const identity = snapshotPlaybookSession(session);
1208
1252
  let finishInitialization;
@@ -1237,8 +1281,7 @@ export const createPlaybookRuntime = (options) => {
1237
1281
  // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1238
1282
  // Defined only at a safe capture point — initialized, not disposing
1239
1283
  // 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.
1284
+ // with status `active` and no pending nested REVIEW call.
1242
1285
  exportSnapshot() {
1243
1286
  if (!actor ||
1244
1287
  !sessionIdentity ||
@@ -1249,6 +1292,8 @@ export const createPlaybookRuntime = (options) => {
1249
1292
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1250
1293
  return undefined;
1251
1294
  }
1295
+ if (nestedBridge.getPendingCall())
1296
+ return undefined;
1252
1297
  const state = currentState();
1253
1298
  if (state.status !== 'active' || !state.quiescent)
1254
1299
  return undefined;
@@ -1258,13 +1303,13 @@ export const createPlaybookRuntime = (options) => {
1258
1303
  schemaVersion: 1,
1259
1304
  playbookId: sessionIdentity.playbookId,
1260
1305
  machine,
1261
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1306
+ playerResumeTokens: snapshotPlayerResumeTokens(),
1262
1307
  sequences: {
1263
1308
  trace: traceSequence,
1264
1309
  turn: turnSequence,
1265
1310
  judgeCall: judgeCallSequence,
1266
1311
  playerCall: playerCallSequence,
1267
- playbookCall: 0,
1312
+ playbookCall: playbookCallSequence,
1268
1313
  },
1269
1314
  state,
1270
1315
  pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
@@ -1285,7 +1330,7 @@ export const createPlaybookRuntime = (options) => {
1285
1330
  initInFlight !== undefined ||
1286
1331
  disposed ||
1287
1332
  disposalPromise !== undefined) {
1288
- throw new Error('discuss runtime: restore(session, snapshot) may only be called once');
1333
+ throw new Error('decide runtime: restore(session, snapshot) may only be called once');
1289
1334
  }
1290
1335
  const identity = snapshotPlaybookSession(session);
1291
1336
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
@@ -1297,29 +1342,40 @@ export const createPlaybookRuntime = (options) => {
1297
1342
  lifecycleStarted = true;
1298
1343
  ports = identity.ports;
1299
1344
  sessionIdentity = identity;
1345
+ let priorExternalPlayerTokens;
1300
1346
  try {
1301
1347
  traceSequence = boundSnapshot.sequences.trace;
1302
1348
  turnSequence = boundSnapshot.sequences.turn;
1303
1349
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1304
1350
  playerCallSequence = boundSnapshot.sequences.playerCall;
1305
- playerResumeTokens.clear();
1306
- for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1307
- playerResumeTokens.set(playerId, token);
1351
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1352
+ if (identity.playerSessions) {
1353
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1308
1354
  }
1355
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1309
1356
  suppressInspectionEmissions = true;
1310
1357
  createRuntimeActor(boundSnapshot.machine);
1311
1358
  actor?.start();
1312
1359
  const restoredState = currentState();
1313
1360
  if (restoredState.status !== 'active') {
1314
- throw new Error(`discuss runtime: restored actor status is ${restoredState.status}, expected active`);
1361
+ throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
1315
1362
  }
1316
1363
  suppressInspectionEmissions = false;
1317
1364
  previousState = restoredState;
1318
1365
  await flush();
1319
1366
  }
1320
1367
  catch (error) {
1368
+ let failure = error;
1369
+ if (priorExternalPlayerTokens !== undefined) {
1370
+ try {
1371
+ identity.playerSessions.restore(priorExternalPlayerTokens);
1372
+ }
1373
+ catch (rollbackError) {
1374
+ failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
1375
+ }
1376
+ }
1321
1377
  await cleanupFailedStart({ emitDisposal: false });
1322
- throw error;
1378
+ throw failure;
1323
1379
  }
1324
1380
  finally {
1325
1381
  finishInitialization();
@@ -1329,14 +1385,14 @@ export const createPlaybookRuntime = (options) => {
1329
1385
  },
1330
1386
  async handleBossInput(turn) {
1331
1387
  if (disposalPromise !== undefined) {
1332
- throw new Error('discuss runtime: runtime is disposing or disposed');
1388
+ throw new Error('decide runtime: runtime is disposing or disposed');
1333
1389
  }
1334
1390
  requirePorts();
1335
1391
  if (!actor) {
1336
- throw new Error('discuss runtime: init(session) must be called before handleBossInput');
1392
+ throw new Error('decide runtime: init(session) must be called before handleBossInput');
1337
1393
  }
1338
- if (currentTurnId !== undefined) {
1339
- throw new Error('discuss runtime: another runtime turn is active');
1394
+ if (currentSignal !== undefined) {
1395
+ throw new Error('decide runtime: another runtime turn is active');
1340
1396
  }
1341
1397
  const turnId = ++turnSequence;
1342
1398
  currentTurnId = turnId;
@@ -1358,6 +1414,9 @@ export const createPlaybookRuntime = (options) => {
1358
1414
  await emitBoundaryStatus('No playbook action classified.', state);
1359
1415
  result = { outcome: 'no-action', state };
1360
1416
  }
1417
+ else if (event.type === 'NO_ACTION') {
1418
+ result = { outcome: 'no-action', state: currentState() };
1419
+ }
1361
1420
  else {
1362
1421
  await emitBoundaryStatus(event.type, currentState());
1363
1422
  if (actor.getSnapshot().status === 'done') {
@@ -1446,25 +1505,68 @@ export const createPlaybookRuntime = (options) => {
1446
1505
  if (failures.length === 1)
1447
1506
  throw failures[0];
1448
1507
  if (failures.length > 1) {
1449
- throw new AggregateError(failures, 'discuss runtime turn failed');
1508
+ throw new AggregateError(failures, 'decide runtime turn failed');
1450
1509
  }
1451
1510
  return result;
1452
1511
  },
1453
- async resumePlaybookCall({ callId, }) {
1512
+ async resumePlaybookCall({ callId, result: childResult, signal, }) {
1454
1513
  if (disposalPromise !== undefined) {
1455
- throw new Error('discuss runtime: runtime is disposing or disposed');
1514
+ throw new Error('decide runtime: runtime is disposing or disposed');
1456
1515
  }
1457
1516
  requirePorts();
1458
1517
  if (!actor) {
1459
- throw new Error('discuss runtime: init(session) must be called before resumePlaybookCall');
1518
+ throw new Error('decide runtime: init(session) must be called before resumePlaybookCall');
1519
+ }
1520
+ if (currentSignal !== undefined) {
1521
+ throw new Error('decide runtime: another runtime turn is active');
1522
+ }
1523
+ currentTurnId = playbookCallTurnIds.get(callId);
1524
+ currentSignal = signal;
1525
+ controlPlaneError = undefined;
1526
+ let runResult;
1527
+ let operationError;
1528
+ try {
1529
+ await nestedBridge.resume({
1530
+ callId,
1531
+ result: childResult,
1532
+ signal,
1533
+ });
1460
1534
  }
1461
- throw new Error(`unknown or stale playbook call id ${callId}`);
1535
+ catch (error) {
1536
+ operationError = error;
1537
+ }
1538
+ try {
1539
+ await waitForPlaybookQuiescence(actor, {
1540
+ pendingCalls: nestedBridge,
1541
+ });
1542
+ runResult = resultForSnapshot(signal);
1543
+ }
1544
+ catch (error) {
1545
+ operationError ??= error;
1546
+ }
1547
+ let drainError;
1548
+ try {
1549
+ await drainBoundaryCallsAndEmissions();
1550
+ }
1551
+ catch (error) {
1552
+ drainError = error;
1553
+ }
1554
+ const failure = controlPlaneError ?? drainError ?? operationError;
1555
+ currentSignal = undefined;
1556
+ currentTurnId = undefined;
1557
+ controlPlaneError = undefined;
1558
+ if (failure !== undefined)
1559
+ throw failure;
1560
+ if (runResult === undefined) {
1561
+ throw new Error('decide runtime: playbook resume produced no result');
1562
+ }
1563
+ return runResult;
1462
1564
  },
1463
1565
  dispose() {
1464
1566
  if (disposalPromise !== undefined)
1465
1567
  return disposalPromise;
1466
- if (currentTurnId !== undefined) {
1467
- return Promise.reject(new Error('discuss runtime: cannot dispose while a runtime turn is active'));
1568
+ if (currentSignal !== undefined) {
1569
+ return Promise.reject(new Error('decide runtime: cannot dispose while a runtime turn is active'));
1468
1570
  }
1469
1571
  disposalPromise = (async () => {
1470
1572
  const initialization = initInFlight;
@@ -1477,6 +1579,12 @@ export const createPlaybookRuntime = (options) => {
1477
1579
  const finalState = currentState();
1478
1580
  const failures = [];
1479
1581
  stopActor();
1582
+ try {
1583
+ await nestedBridge.dispose();
1584
+ }
1585
+ catch (error) {
1586
+ collectFailure(failures, error);
1587
+ }
1480
1588
  try {
1481
1589
  await drainBoundaryCallsAndEmissions();
1482
1590
  }
@@ -1500,6 +1608,7 @@ export const createPlaybookRuntime = (options) => {
1500
1608
  }
1501
1609
  finally {
1502
1610
  playerResumeTokens.clear();
1611
+ playbookCallTurnIds.clear();
1503
1612
  inFlightPlayerIds.clear();
1504
1613
  activeBoundaryCalls.clear();
1505
1614
  activeEmissionCalls.clear();
@@ -1517,7 +1626,7 @@ export const createPlaybookRuntime = (options) => {
1517
1626
  if (failures.length === 1)
1518
1627
  throw failures[0];
1519
1628
  if (failures.length > 1) {
1520
- throw new AggregateError(failures, 'discuss runtime disposal failed');
1629
+ throw new AggregateError(failures, 'decide runtime disposal failed');
1521
1630
  }
1522
1631
  })();
1523
1632
  return disposalPromise;
@@ -1538,10 +1647,10 @@ export const _internal = {
1538
1647
  normalizeErrorCompact,
1539
1648
  normalizeErrorFull,
1540
1649
  DEFAULT_PLAYER_BINDING,
1541
- ALIAS_RESOLUTION,
1542
1650
  STATE_DESCRIPTIONS,
1543
- CAPTAIN_STATES,
1544
- CAPTAIN_STATE_IDS,
1651
+ PLAYER_STATES,
1652
+ PLAYER_STATE_IDS,
1653
+ VERBATIM_PAYLOAD_FIELDS,
1545
1654
  BOSS_INTERRUPT_TARGETS,
1546
1655
  CONTINUATION_PREAMBLE,
1547
1656
  TELEMETRY_TOPIC,