@sublang/playbook 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +69 -122
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +43 -26
  4. package/docs/configuration.md +63 -18
  5. package/docs/embedding.md +24 -16
  6. package/package.json +43 -22
  7. package/reference/sdlc/captain.md +70 -83
  8. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  9. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  10. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  11. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  12. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  13. package/reference/sdlc/captain.playbook/captain.playbook.js +466 -976
  14. package/reference/sdlc/captain.playbook/captain.playbook.ts +698 -1001
  15. package/reference/sdlc/code.md +55 -97
  16. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  18. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  19. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  20. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  21. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  22. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  23. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  24. package/reference/sdlc/code.playbook/code.playbook.js +69 -639
  25. package/reference/sdlc/code.playbook/code.playbook.ts +90 -850
  26. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  27. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  28. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  29. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  30. package/reference/sdlc/code.playbook/playbook-captain.js +1877 -251
  31. package/reference/sdlc/code.playbook/playbook-captain.ts +2385 -352
  32. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  33. package/reference/sdlc/decide.md +54 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  35. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  36. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  37. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  38. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  39. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +511 -370
  40. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +616 -451
  41. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  42. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  43. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  44. package/reference/sdlc/review.md +81 -0
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  47. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  48. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  50. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  51. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  52. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  53. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  54. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  55. package/slc/gears2fsm.md +67 -6
  56. package/slc/link.md +339 -25
  57. package/slc/text2gears.md +22 -2
  58. package/src/runtime.d.ts +36 -1
  59. package/src/runtime.ts +59 -0
  60. package/src/xstate-playbook-runtime.d.ts +96 -7
  61. package/src/xstate-playbook-runtime.js +1018 -49
  62. package/src/xstate-playbook-runtime.ts +1283 -59
  63. package/src/xstate-runtime.js +25 -0
  64. package/src/xstate-runtime.ts +51 -0
  65. package/reference/sdlc/discuss.md +0 -93
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  68. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  69. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  70. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  71. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  72. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  73. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -1,205 +1,97 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  //
4
- // PlaybookRuntime for the discuss playbook, linked from the FSM artifact by
4
+ // PlaybookRuntime for the decide playbook, linked from the FSM artifact by
5
5
  // the slc FSM-to-runtime link phase.
6
6
  //
7
7
  // Linker inputs:
8
- // FSM artifact: ./discuss.fsm.ts
9
- // Link target: @sublang/playbook/src/runtime.ts
10
- // Player binding: Host -> host, Participant -> participant,
11
- // Committer -> committer
8
+ // FSM artifact: ./decide.fsm.ts
9
+ // Link target: @sublang/playbook/runtime
10
+ // Player binding: Coder -> coder, Reviewer -> reviewer
12
11
  // (default binding: lowercased player name)
13
- // Composite players: Committer = Host | Participant. DISCUSS-14 and
14
- // DISCUSS-15 keep PlayerInput.player = 'Committer';
15
- // callPlayer resolution uses options.committer when
16
- // supplied, otherwise falls back to Host, the first
17
- // listed alias alternative.
18
12
  // Adjudication: LLM-judge per state (default)
19
13
  // Boss-event mapping: free-text judge classification (default)
20
14
  // Abort strategy: natural rejection; every player-invoking state's
21
15
  // onError routes to the quiescent failed state.
16
+ // Output profile: bespoke parallel runtime with the shared nested-call
17
+ // bridge (slc/link.md §Output)
22
18
  import PQueue from 'p-queue';
23
19
  import { createActor, fromPromise } from 'xstate';
24
- import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
25
- import discussMachine from './discuss.fsm.js';
20
+ import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
21
+ import decideMachine from './decide.fsm.js';
26
22
  const DEFAULT_PLAYER_BINDING = {
27
- Host: 'host',
28
- Participant: 'participant',
29
- Committer: 'committer',
23
+ Coder: 'coder',
24
+ Reviewer: 'reviewer',
30
25
  };
31
- const ALIAS_RESOLUTION = {
32
- 'DISCUSS-14': 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
33
- 'DISCUSS-15': 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
34
- };
35
- function snapshotDiscussRuntimeOptions(value) {
36
- const captured = snapshotJsonValue(value, 'DISCUSS runtime options');
26
+ function snapshotDecideRuntimeOptions(value) {
27
+ const captured = snapshotJsonValue(value, 'DECIDE runtime options');
37
28
  if (!isPlainObject(captured)) {
38
- throw new TypeError('DISCUSS runtime options must be an object');
29
+ throw new TypeError('DECIDE runtime options must be an object');
39
30
  }
40
- const allowed = new Set([
41
- 'host',
42
- 'participant',
43
- 'committer',
44
- 'playerBinding',
45
- ]);
31
+ const allowed = new Set(['coderLlm', 'playerBinding']);
46
32
  for (const key of Object.keys(captured)) {
47
33
  if (!allowed.has(key)) {
48
- throw new TypeError(`DISCUSS runtime options.${key} is not declared`);
34
+ throw new TypeError(`DECIDE runtime options.${key} is not declared`);
49
35
  }
50
36
  }
51
- for (const key of ['host', 'participant', 'committer']) {
52
- if (key in captured && typeof captured[key] !== 'string') {
53
- throw new TypeError(`DISCUSS runtime options.${key} must be a string`);
54
- }
37
+ if (typeof captured.coderLlm !== 'string' ||
38
+ captured.coderLlm.trim().length === 0) {
39
+ throw new TypeError('DECIDE runtime options.coderLlm must be a non-empty string');
55
40
  }
56
41
  if ('playerBinding' in captured) {
57
42
  const playerBinding = captured.playerBinding;
58
43
  if (!isPlainObject(playerBinding)) {
59
- throw new TypeError('DISCUSS runtime options.playerBinding must be an object');
44
+ throw new TypeError('DECIDE runtime options.playerBinding must be an object');
60
45
  }
61
- const playerNames = new Set([
62
- 'Host',
63
- 'Participant',
64
- 'Committer',
65
- ]);
46
+ const playerNames = new Set(['Coder', 'Reviewer']);
66
47
  for (const [player, playerId] of Object.entries(playerBinding)) {
67
48
  if (!playerNames.has(player)) {
68
- throw new TypeError(`DISCUSS runtime options.playerBinding.${player} is not declared`);
49
+ throw new TypeError(`DECIDE runtime options.playerBinding.${player} is not declared`);
69
50
  }
70
51
  if (typeof playerId !== 'string' || playerId.trim().length === 0) {
71
- throw new TypeError(`DISCUSS runtime options.playerBinding.${player} must be a non-empty string`);
52
+ throw new TypeError(`DECIDE runtime options.playerBinding.${player} must be a non-empty string`);
72
53
  }
73
54
  }
74
55
  }
75
56
  return captured;
76
57
  }
77
58
  const STATE_DESCRIPTIONS = {
78
- ready: 'Idle hub awaiting a Boss discussion or review directive.',
79
- askHostInitial: 'Host proposes whether the Boss topic should become spec items or DRs.',
80
- askParticipantInitial: 'Participant independently proposes whether the Boss topic should become spec items or DRs.',
81
- hostInitialRound: 'Host reconciles the Participant proposal during initial discussion.',
82
- participantInitialRound: 'Participant reconciles the Host proposal during initial discussion.',
83
- hostWritesAgreement: 'Host writes the agreed spec items or DRs and updates the spec map.',
84
- commitInitialChanges: 'Committer commits the changes produced at the end of initial discussion.',
85
- reviewSpecInitialCommit: 'Participant reviews newly committed spec-item changes.',
86
- reviewSpecHostChanges: 'Participant reviews Host changes to spec items after findings.',
87
- reviewDrInitialCommit: 'Participant reviews newly committed decision-record changes.',
88
- reviewDrHostChanges: 'Participant reviews Host changes to decision records after findings.',
89
- reviewMixedInitialCommit: 'Participant reviews newly committed mixed spec-item and DR changes.',
90
- reviewMixedHostChanges: 'Participant reviews Host changes to mixed spec items and DRs after findings.',
91
- hostAddressesFindings: 'Host accepts or challenges review findings and stages repo changes.',
92
- participantAddressesRebuttals: 'Participant accepts or challenges Host rebuttals.',
93
- commitReviewedChanges: 'Committer commits reviewed changes once Participant raises no findings.',
94
- awaitBossReply: 'Waiting for Boss to answer a player question.',
95
- failed: 'The discussion workflow failed and is waiting for Boss recovery.',
96
- done: 'The discussion workflow completed with a reviewed commit.',
59
+ ready: 'Waiting for a topic to decide.',
60
+ askCoderProposal: 'Coder independently proposes a spec design.',
61
+ askReviewerProposal: 'Reviewer independently proposes a spec design.',
62
+ waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
63
+ waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
64
+ commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
65
+ awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
66
+ reviewCommit: 'REVIEW examines the committed proposal.',
67
+ failed: 'DECIDE failed and is waiting for a new topic.',
68
+ reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
69
+ done: 'DECIDE completed with an approved commit.',
97
70
  };
98
- const CAPTAIN_STATES = [
99
- { stateId: 'askHostInitial', player: 'Host', sourceItem: 'DISCUSS-1' },
100
- {
101
- stateId: 'askParticipantInitial',
102
- player: 'Participant',
103
- sourceItem: 'DISCUSS-2',
104
- },
105
- { stateId: 'hostInitialRound', player: 'Host', sourceItem: 'DISCUSS-3' },
106
- {
107
- stateId: 'participantInitialRound',
108
- player: 'Participant',
109
- sourceItem: 'DISCUSS-4',
110
- },
111
- { stateId: 'hostWritesAgreement', player: 'Host', sourceItem: 'DISCUSS-5' },
112
- {
113
- stateId: 'commitInitialChanges',
114
- player: 'Committer',
115
- sourceItem: 'DISCUSS-14',
116
- },
117
- {
118
- stateId: 'reviewSpecInitialCommit',
119
- player: 'Participant',
120
- sourceItem: 'DISCUSS-6',
121
- },
122
- {
123
- stateId: 'reviewSpecHostChanges',
124
- player: 'Participant',
125
- sourceItem: 'DISCUSS-7',
126
- },
127
- {
128
- stateId: 'reviewDrInitialCommit',
129
- player: 'Participant',
130
- sourceItem: 'DISCUSS-8',
131
- },
132
- {
133
- stateId: 'reviewDrHostChanges',
134
- player: 'Participant',
135
- sourceItem: 'DISCUSS-9',
136
- },
137
- {
138
- stateId: 'reviewMixedInitialCommit',
139
- player: 'Participant',
140
- sourceItem: 'DISCUSS-10',
141
- },
142
- {
143
- stateId: 'reviewMixedHostChanges',
144
- player: 'Participant',
145
- sourceItem: 'DISCUSS-11',
146
- },
147
- {
148
- stateId: 'hostAddressesFindings',
149
- player: 'Host',
150
- sourceItem: 'DISCUSS-12',
151
- },
71
+ const PLAYER_STATES = [
72
+ { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
152
73
  {
153
- stateId: 'participantAddressesRebuttals',
154
- player: 'Participant',
155
- sourceItem: 'DISCUSS-13',
74
+ stateId: 'askReviewerProposal',
75
+ player: 'Reviewer',
76
+ sourceItem: 'DECIDE-2',
156
77
  },
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
  }
@@ -520,12 +409,15 @@ function normalizeErrorCompact(err) {
520
409
  function normalizeErrorFull(err) {
521
410
  return err === undefined || err === null ? undefined : normalizeError(err);
522
411
  }
412
+ // DR-028's unified empty predicate: a missing, empty, or whitespace-only
413
+ // `finalText` on an `ok` player result is the one shape that earns exactly
414
+ // one corrective re-ask before the existing failure path applies. Mirrors
415
+ // the shared engine's predicate (PBRT-9).
416
+ function isEmptyFinalText(finalText) {
417
+ return finalText === undefined || finalText.trim().length === 0;
418
+ }
523
419
  function isAbortFailure(error, signal) {
524
- if (!signal.aborted)
525
- return false;
526
- if (Object.is(error, signal.reason))
527
- return true;
528
- return normalizeErrorCompact(error)?.name === 'AbortError';
420
+ return signal.aborted && Object.is(error, signal.reason);
529
421
  }
530
422
  function pendingQuestionsFromContext(context) {
531
423
  const pending = context.pendingBossQuestions;
@@ -552,17 +444,15 @@ function pendingQuestionsFromContext(context) {
552
444
  return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
553
445
  }
554
446
  const WAIT_STATE_RESUME_IDS = {
555
- waitHostInitialReply: 'askHostInitial',
556
- waitParticipantInitialReply: 'askParticipantInitial',
557
- waitHostReconciliationReply: 'hostInitialRound',
558
- waitParticipantReconciliationReply: 'participantInitialRound',
447
+ waitCoderProposalReply: 'askCoderProposal',
448
+ waitReviewerProposalReply: 'askReviewerProposal',
559
449
  };
560
450
  const WAIT_STATE_IDS = new Set([
561
451
  ...Object.keys(WAIT_STATE_RESUME_IDS),
562
452
  'awaitBossReply',
563
453
  ]);
564
454
  const STATUS_STATE_IDS = new Set([
565
- ...CAPTAIN_STATE_IDS,
455
+ ...PLAYER_STATE_IDS,
566
456
  ...WAIT_STATE_IDS,
567
457
  'failed',
568
458
  ]);
@@ -575,38 +465,44 @@ function questionForWaitState(stateId, pendingQuestions) {
575
465
  ? pendingQuestions[0]
576
466
  : undefined;
577
467
  }
578
- function normalizedEventType(event) {
579
- if (event !== null &&
580
- typeof event === 'object' &&
581
- !Array.isArray(event) &&
582
- 'type' in event) {
583
- const type = event.type;
584
- 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
+ }
585
487
  }
586
- if (event === null ||
587
- typeof event === 'string' ||
588
- typeof event === 'boolean' ||
589
- (typeof event === 'number' && Number.isFinite(event))) {
590
- return event;
488
+ if (source.output !== undefined) {
489
+ descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
591
490
  }
592
- return String(event);
491
+ if (source.error !== undefined) {
492
+ descriptor.error = snapshotJsonValue(normalizeError(source.error), 'FSM event error');
493
+ }
494
+ return snapshotJsonValue(descriptor, 'FSM event');
593
495
  }
594
496
  function telemetryPayload(previousState, state, event, context) {
595
- const eventError = event !== null &&
596
- typeof event === 'object' &&
597
- !Array.isArray(event) &&
598
- 'error' in event
599
- ? normalizeErrorFull(event.error)
600
- : undefined;
601
497
  const pendingBossQuestions = pendingQuestionsFromContext(context);
498
+ const prior = previousState ?? state;
602
499
  const payload = {
603
- from: previousState?.value ?? null,
500
+ from: prior.value,
604
501
  to: state.value,
605
- event: normalizedEventType(event),
606
- previousState: previousState ?? null,
502
+ event: normalizedTransitionEvent(event),
503
+ previousState: prior,
607
504
  state,
608
505
  ...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
609
- ...(eventError !== undefined ? { error: eventError } : {}),
610
506
  ...(state.activeStateIds.includes('failed')
611
507
  ? { lastError: normalizeErrorFull(context.lastError) ?? null }
612
508
  : {}),
@@ -615,15 +511,13 @@ function telemetryPayload(previousState, state, event, context) {
615
511
  return payload;
616
512
  }
617
513
  export const createPlaybookRuntime = (options) => {
618
- const boundOptions = snapshotDiscussRuntimeOptions(options);
514
+ const boundOptions = snapshotDecideRuntimeOptions(options);
619
515
  const binding = {
620
516
  ...DEFAULT_PLAYER_BINDING,
621
517
  ...(boundOptions.playerBinding ?? {}),
622
518
  };
623
519
  const fsmInput = {
624
- host: boundOptions.host,
625
- participant: boundOptions.participant,
626
- committer: boundOptions.committer,
520
+ coderLlm: boundOptions.coderLlm,
627
521
  };
628
522
  let ports;
629
523
  let sessionIdentity;
@@ -637,12 +531,15 @@ export const createPlaybookRuntime = (options) => {
637
531
  let turnSequence = 0;
638
532
  let judgeCallSequence = 0;
639
533
  let playerCallSequence = 0;
534
+ let playbookCallSequence = 0;
640
535
  let lifecycleStarted = false;
641
536
  let initInFlight;
642
537
  let disposed = false;
643
538
  let disposalPromise;
644
539
  let controlPlaneError;
540
+ let nestedBridge;
645
541
  const playerResumeTokens = new Map();
542
+ const playbookCallTurnIds = new Map();
646
543
  const inFlightPlayerIds = new Set();
647
544
  const activeBoundaryCalls = new Set();
648
545
  const activeEmissionCalls = new Set();
@@ -662,6 +559,14 @@ export const createPlaybookRuntime = (options) => {
662
559
  if (!isAbortFailure(error, signal))
663
560
  controlPlaneError ??= error;
664
561
  };
562
+ const latchInspectionError = (error) => {
563
+ if (currentSignal !== undefined) {
564
+ latchControlPlaneError(error, currentSignal);
565
+ }
566
+ else {
567
+ collectFailure(emissionFailures, error);
568
+ }
569
+ };
665
570
  const enqueue = (fn) => {
666
571
  const queued = emissionQueue.add(fn);
667
572
  activeEmissionCalls.add(queued);
@@ -689,7 +594,7 @@ export const createPlaybookRuntime = (options) => {
689
594
  emissionFailures = [];
690
595
  if (failures.length === 1)
691
596
  throw failures[0];
692
- throw new AggregateError(failures, 'discuss runtime emissions failed');
597
+ throw new AggregateError(failures, 'decide runtime emissions failed');
693
598
  };
694
599
  const drainBoundaryCallsAndEmissions = async () => {
695
600
  while (true) {
@@ -708,22 +613,78 @@ export const createPlaybookRuntime = (options) => {
708
613
  };
709
614
  const requirePorts = () => {
710
615
  if (!ports) {
711
- throw new Error('discuss runtime: init(session) must be called first');
616
+ throw new Error('decide runtime: init(session) must be called first');
712
617
  }
713
618
  return ports;
714
619
  };
715
620
  const requireSessionIdentity = () => {
716
621
  if (!sessionIdentity) {
717
- throw new Error('discuss runtime: init(session) must be called first');
622
+ throw new Error('decide runtime: init(session) must be called first');
718
623
  }
719
624
  return sessionIdentity;
720
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
+ };
721
680
  const currentState = () => {
722
681
  const live = actor;
723
682
  if (!live) {
724
- throw new Error('discuss runtime: actor is not initialized');
683
+ throw new Error('decide runtime: actor is not initialized');
725
684
  }
726
- return normalizePlaybookSnapshot(live.getSnapshot());
685
+ return normalizePlaybookSnapshot(live.getSnapshot(), {
686
+ pendingCall: nestedBridge.getPendingCall(),
687
+ });
727
688
  };
728
689
  const stateIdentity = (state) => {
729
690
  return state.stateId === undefined ? {} : { stateId: state.stateId };
@@ -795,7 +756,7 @@ export const createPlaybookRuntime = (options) => {
795
756
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
796
757
  };
797
758
  const queued = await judgeQueue.add(async () => {
798
- // Keep the complete queue task pending until an active host promise
759
+ // Keep the complete queue task pending until an active coder promise
799
760
  // settles. PQueue's signal option may reject add() while that task is
800
761
  // still running, which would let the turn drain race a late finish.
801
762
  signal.throwIfAborted();
@@ -839,9 +800,16 @@ export const createPlaybookRuntime = (options) => {
839
800
  if (inFlightPlayerIds.has(playerId)) {
840
801
  throw new Error(`resolved player "${playerId}" already has an in-flight call`);
841
802
  }
842
- inFlightPlayerIds.add(playerId);
843
803
  const prompt = composePlayerPrompt(input);
844
- 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
+ }
845
813
  const callId = `player-${++playerCallSequence}`;
846
814
  const identity = {
847
815
  purpose: 'captain',
@@ -858,6 +826,7 @@ export const createPlaybookRuntime = (options) => {
858
826
  message: String(error),
859
827
  },
860
828
  }, { turnId: currentTurnId, callId });
829
+ inFlightPlayerIds.add(playerId);
861
830
  try {
862
831
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
863
832
  let rawResult;
@@ -865,14 +834,14 @@ export const createPlaybookRuntime = (options) => {
865
834
  signal.throwIfAborted();
866
835
  const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
867
836
  rawResult = await boundary;
868
- // An XState sibling cancellation does not cancel an arbitrary host
837
+ // An XState sibling cancellation does not cancel an arbitrary coder
869
838
  // promise. Re-check before a late resolution can mutate continuity or
870
839
  // masquerade as a successful boundary finish.
871
840
  signal.throwIfAborted();
872
841
  }
873
842
  catch (error) {
874
843
  // A rejected call produced no authoritative result, so the previous
875
- // token remains untouched. This also covers a host promise that
844
+ // token remains untouched. This also covers a coder promise that
876
845
  // resolves after its invocation signal was cancelled.
877
846
  latchControlPlaneError(error, signal);
878
847
  try {
@@ -893,18 +862,27 @@ export const createPlaybookRuntime = (options) => {
893
862
  await emitFailure(error);
894
863
  }
895
864
  catch {
896
- // The malformed host result remains authoritative.
865
+ // The malformed coder result remains authoritative.
897
866
  }
898
867
  throw error;
899
868
  }
900
869
  // The resolved result is authoritative even on aborted/error status.
901
870
  // Update continuation state before interpreting that status.
902
- if (typeof result.resumeToken === 'string' &&
903
- result.resumeToken.trim().length > 0) {
904
- 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);
905
876
  }
906
- else {
907
- 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;
908
886
  }
909
887
  // Keep this finish outside the boundary catch. A trace sink can record
910
888
  // the event and then reject; retrying from that catch would duplicate the
@@ -944,24 +922,85 @@ export const createPlaybookRuntime = (options) => {
944
922
  throw error;
945
923
  }
946
924
  combined.throwIfAborted();
947
- const { playerId, result } = await callPlayer(input, combined);
925
+ let { playerId, result } = await callPlayer(input, combined);
926
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
927
+ // DR-028: an `ok` result whose finalText is missing, empty, or
928
+ // whitespace-only earns exactly one corrective re-ask — the same
929
+ // composed call repeated, traced by runPlayerCall as its own
930
+ // player-call pair, with the resume selection re-read from the
931
+ // token map the first result left (PBRT-38). An abort that lands
932
+ // between the two calls ends the turn without the re-ask (aborts
933
+ // are never retried), and a rejecting finish emission rejects
934
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
935
+ combined.throwIfAborted();
936
+ ({ playerId, result } = await callPlayer(input, combined));
937
+ }
948
938
  if (result.status !== 'ok') {
949
939
  throw new Error(`player "${playerId}" returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
950
940
  }
951
- if (result.finalText === undefined) {
941
+ const finalText = result.finalText ?? '';
942
+ if (isEmptyFinalText(finalText)) {
952
943
  throw new Error(`player "${playerId}" returned status "ok" with no finalText`);
953
944
  }
954
945
  combined.throwIfAborted();
955
946
  try {
956
- const prompt = buildAdjudicatorPrompt(input, result.finalText);
957
- return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
947
+ const prompt = buildAdjudicatorPrompt(input, finalText);
948
+ return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
958
949
  }
959
950
  catch (error) {
960
951
  latchControlPlaneError(error, combined);
961
952
  throw error;
962
953
  }
963
954
  });
964
- 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
+ });
965
1004
  const inspect = (event) => {
966
1005
  if (event.type !== '@xstate.snapshot')
967
1006
  return;
@@ -969,49 +1008,58 @@ export const createPlaybookRuntime = (options) => {
969
1008
  return;
970
1009
  if (suppressInspectionEmissions)
971
1010
  return;
972
- const snapshot = event.snapshot;
973
- const state = normalizePlaybookSnapshot(snapshot);
974
- const prior = previousState;
975
- previousState = state;
976
- const runtimePorts = ports;
977
- if (!runtimePorts)
978
- return;
979
- const context = snapshot.context;
980
- const fsmPayload = telemetryPayload(prior, state, event.event, context);
981
- const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
982
- void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
983
- topic: TELEMETRY_TOPIC,
984
- payload: describedFsmPayload,
985
- })).catch(() => undefined);
986
- const priorIds = new Set(prior?.activeStateIds ?? []);
987
- const pendingQuestions = pendingQuestionsFromContext(context);
988
- const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
989
- const scheduleStatus = (message, stateId, data) => {
990
- const tracePayload = {
991
- ...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
992
- message,
993
- state,
994
- ...(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);
995
1035
  };
996
- assertJsonSafe(tracePayload);
997
- void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
998
- };
999
- for (const activeStateId of state.activeStateIds) {
1000
- if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
1001
- continue;
1002
- }
1003
- if (WAIT_STATE_IDS.has(activeStateId)) {
1004
- const pending = questionForWaitState(activeStateId, pendingQuestions);
1005
- if (pending) {
1006
- scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1007
- 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;
1008
1040
  }
1009
- 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 });
1010
1059
  }
1011
- const lastError = activeStateId === 'failed'
1012
- ? normalizeErrorCompact(context.lastError)
1013
- : undefined;
1014
- scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
1060
+ }
1061
+ catch (error) {
1062
+ latchInspectionError(error);
1015
1063
  }
1016
1064
  };
1017
1065
  const createRuntimeActor = (machineSnapshot) => {
@@ -1028,37 +1076,63 @@ export const createPlaybookRuntime = (options) => {
1028
1076
  inspect,
1029
1077
  });
1030
1078
  };
1079
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
1080
+ // still-running actor fires one more `@xstate.snapshot` for the *unchanged*
1081
+ // state value with `status: 'stopped'`; `inspect` cannot tell that disposal
1082
+ // artifact from a state entry, so unsuppressed it re-emits the parked
1083
+ // state's telemetry and a phantom self-loop transition. Suppression is a
1084
+ // property of stopping, not a rule each caller must remember — every stop
1085
+ // goes through here so no later site can reintroduce the omission.
1086
+ const stopActor = () => {
1087
+ if (!actor)
1088
+ return;
1089
+ suppressInspectionEmissions = true;
1090
+ actor.stop();
1091
+ };
1031
1092
  const startActor = () => {
1032
1093
  createRuntimeActor();
1094
+ // A fresh actor's emissions are real state entries again.
1095
+ suppressInspectionEmissions = false;
1033
1096
  actor?.start();
1034
1097
  };
1035
1098
  const driveToQuiescence = async () => {
1036
1099
  const live = actor;
1037
1100
  if (!live)
1038
- throw new Error('discuss runtime: actor is not initialized');
1039
- await waitForPlaybookQuiescence(live);
1101
+ throw new Error('decide runtime: actor is not initialized');
1102
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
1040
1103
  };
1041
1104
  const classify = async (text, signal) => {
1042
1105
  const live = actor;
1043
1106
  if (!live)
1044
- throw new Error('discuss runtime: actor is not initialized');
1107
+ throw new Error('decide runtime: actor is not initialized');
1045
1108
  const snapshot = live.getSnapshot();
1046
1109
  const context = snapshot.context;
1047
- const state = normalizePlaybookSnapshot(snapshot);
1110
+ const state = normalizePlaybookSnapshot(snapshot, {
1111
+ pendingCall: nestedBridge.getPendingCall(),
1112
+ });
1048
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;
1049
1122
  const prompt = buildClassifierPrompt(text, {
1050
1123
  state,
1051
1124
  pendingQuestions,
1052
1125
  });
1053
1126
  const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
1054
- return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
1127
+ return parseClassification(raw, text, pendingQuestions.map(({ questionId }) => questionId));
1055
1128
  };
1056
1129
  const resultForSnapshot = (signal) => {
1057
1130
  const live = actor;
1058
1131
  if (!live)
1059
- throw new Error('discuss runtime: actor is not initialized');
1132
+ throw new Error('decide runtime: actor is not initialized');
1060
1133
  const snapshot = live.getSnapshot();
1061
- const state = normalizePlaybookSnapshot(snapshot);
1134
+ const pendingCall = nestedBridge.getPendingCall();
1135
+ const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1062
1136
  const context = snapshot.context;
1063
1137
  if (signal?.aborted) {
1064
1138
  return {
@@ -1086,7 +1160,7 @@ export const createPlaybookRuntime = (options) => {
1086
1160
  }
1087
1161
  if (snapshot.status === 'error') {
1088
1162
  throw (snapshot.error ??
1089
- new Error('discuss runtime actor entered error status'));
1163
+ new Error('decide runtime actor entered error status'));
1090
1164
  }
1091
1165
  if (state.activeStateIds.includes('failed')) {
1092
1166
  const error = normalizeErrorFull(context.lastError);
@@ -1096,6 +1170,9 @@ export const createPlaybookRuntime = (options) => {
1096
1170
  ...(error === undefined ? {} : { error }),
1097
1171
  };
1098
1172
  }
1173
+ if (pendingCall) {
1174
+ return { outcome: 'suspended', state, pendingCall };
1175
+ }
1099
1176
  return { outcome: 'quiescent', state };
1100
1177
  };
1101
1178
  // Shared failed-start cleanup for init and restore: stop the actor,
@@ -1114,9 +1191,8 @@ export const createPlaybookRuntime = (options) => {
1114
1191
  // A state that cannot even normalize has no disposal descriptor.
1115
1192
  }
1116
1193
  }
1117
- suppressInspectionEmissions = true;
1118
1194
  try {
1119
- actor?.stop();
1195
+ stopActor();
1120
1196
  }
1121
1197
  catch {
1122
1198
  // Preserve the original startup failure.
@@ -1160,6 +1236,8 @@ export const createPlaybookRuntime = (options) => {
1160
1236
  turnSequence = 0;
1161
1237
  judgeCallSequence = 0;
1162
1238
  playerCallSequence = 0;
1239
+ playbookCallSequence = 0;
1240
+ playbookCallTurnIds.clear();
1163
1241
  lifecycleStarted = false;
1164
1242
  };
1165
1243
  return {
@@ -1168,7 +1246,7 @@ export const createPlaybookRuntime = (options) => {
1168
1246
  initInFlight !== undefined ||
1169
1247
  disposed ||
1170
1248
  disposalPromise !== undefined) {
1171
- 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');
1172
1250
  }
1173
1251
  const identity = snapshotPlaybookSession(session);
1174
1252
  let finishInitialization;
@@ -1203,8 +1281,7 @@ export const createPlaybookRuntime = (options) => {
1203
1281
  // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1204
1282
  // Defined only at a safe capture point — initialized, not disposing
1205
1283
  // or disposed, no active public boundary, and the actor quiescent
1206
- // with status `active`. DISCUSS never opens nested playbook calls,
1207
- // so no pending-call guard applies.
1284
+ // with status `active` and no pending nested REVIEW call.
1208
1285
  exportSnapshot() {
1209
1286
  if (!actor ||
1210
1287
  !sessionIdentity ||
@@ -1215,6 +1292,8 @@ export const createPlaybookRuntime = (options) => {
1215
1292
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1216
1293
  return undefined;
1217
1294
  }
1295
+ if (nestedBridge.getPendingCall())
1296
+ return undefined;
1218
1297
  const state = currentState();
1219
1298
  if (state.status !== 'active' || !state.quiescent)
1220
1299
  return undefined;
@@ -1224,13 +1303,13 @@ export const createPlaybookRuntime = (options) => {
1224
1303
  schemaVersion: 1,
1225
1304
  playbookId: sessionIdentity.playbookId,
1226
1305
  machine,
1227
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1306
+ playerResumeTokens: snapshotPlayerResumeTokens(),
1228
1307
  sequences: {
1229
1308
  trace: traceSequence,
1230
1309
  turn: turnSequence,
1231
1310
  judgeCall: judgeCallSequence,
1232
1311
  playerCall: playerCallSequence,
1233
- playbookCall: 0,
1312
+ playbookCall: playbookCallSequence,
1234
1313
  },
1235
1314
  state,
1236
1315
  pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
@@ -1251,7 +1330,7 @@ export const createPlaybookRuntime = (options) => {
1251
1330
  initInFlight !== undefined ||
1252
1331
  disposed ||
1253
1332
  disposalPromise !== undefined) {
1254
- 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');
1255
1334
  }
1256
1335
  const identity = snapshotPlaybookSession(session);
1257
1336
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
@@ -1263,29 +1342,40 @@ export const createPlaybookRuntime = (options) => {
1263
1342
  lifecycleStarted = true;
1264
1343
  ports = identity.ports;
1265
1344
  sessionIdentity = identity;
1345
+ let priorExternalPlayerTokens;
1266
1346
  try {
1267
1347
  traceSequence = boundSnapshot.sequences.trace;
1268
1348
  turnSequence = boundSnapshot.sequences.turn;
1269
1349
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1270
1350
  playerCallSequence = boundSnapshot.sequences.playerCall;
1271
- playerResumeTokens.clear();
1272
- for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1273
- playerResumeTokens.set(playerId, token);
1351
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1352
+ if (identity.playerSessions) {
1353
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1274
1354
  }
1355
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1275
1356
  suppressInspectionEmissions = true;
1276
1357
  createRuntimeActor(boundSnapshot.machine);
1277
1358
  actor?.start();
1278
1359
  const restoredState = currentState();
1279
1360
  if (restoredState.status !== 'active') {
1280
- 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`);
1281
1362
  }
1282
1363
  suppressInspectionEmissions = false;
1283
1364
  previousState = restoredState;
1284
1365
  await flush();
1285
1366
  }
1286
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
+ }
1287
1377
  await cleanupFailedStart({ emitDisposal: false });
1288
- throw error;
1378
+ throw failure;
1289
1379
  }
1290
1380
  finally {
1291
1381
  finishInitialization();
@@ -1295,14 +1385,14 @@ export const createPlaybookRuntime = (options) => {
1295
1385
  },
1296
1386
  async handleBossInput(turn) {
1297
1387
  if (disposalPromise !== undefined) {
1298
- throw new Error('discuss runtime: runtime is disposing or disposed');
1388
+ throw new Error('decide runtime: runtime is disposing or disposed');
1299
1389
  }
1300
1390
  requirePorts();
1301
1391
  if (!actor) {
1302
- 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');
1303
1393
  }
1304
- if (currentTurnId !== undefined) {
1305
- 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');
1306
1396
  }
1307
1397
  const turnId = ++turnSequence;
1308
1398
  currentTurnId = turnId;
@@ -1324,10 +1414,13 @@ export const createPlaybookRuntime = (options) => {
1324
1414
  await emitBoundaryStatus('No playbook action classified.', state);
1325
1415
  result = { outcome: 'no-action', state };
1326
1416
  }
1417
+ else if (event.type === 'NO_ACTION') {
1418
+ result = { outcome: 'no-action', state: currentState() };
1419
+ }
1327
1420
  else {
1328
1421
  await emitBoundaryStatus(event.type, currentState());
1329
1422
  if (actor.getSnapshot().status === 'done') {
1330
- actor.stop();
1423
+ stopActor();
1331
1424
  startActor();
1332
1425
  }
1333
1426
  actor.send(event);
@@ -1412,25 +1505,68 @@ export const createPlaybookRuntime = (options) => {
1412
1505
  if (failures.length === 1)
1413
1506
  throw failures[0];
1414
1507
  if (failures.length > 1) {
1415
- throw new AggregateError(failures, 'discuss runtime turn failed');
1508
+ throw new AggregateError(failures, 'decide runtime turn failed');
1416
1509
  }
1417
1510
  return result;
1418
1511
  },
1419
- async resumePlaybookCall({ callId, }) {
1512
+ async resumePlaybookCall({ callId, result: childResult, signal, }) {
1420
1513
  if (disposalPromise !== undefined) {
1421
- throw new Error('discuss runtime: runtime is disposing or disposed');
1514
+ throw new Error('decide runtime: runtime is disposing or disposed');
1422
1515
  }
1423
1516
  requirePorts();
1424
1517
  if (!actor) {
1425
- 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
+ });
1534
+ }
1535
+ catch (error) {
1536
+ operationError = error;
1426
1537
  }
1427
- throw new Error(`unknown or stale playbook call id ${callId}`);
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;
1428
1564
  },
1429
1565
  dispose() {
1430
1566
  if (disposalPromise !== undefined)
1431
1567
  return disposalPromise;
1432
- if (currentTurnId !== undefined) {
1433
- 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'));
1434
1570
  }
1435
1571
  disposalPromise = (async () => {
1436
1572
  const initialization = initInFlight;
@@ -1442,8 +1578,12 @@ export const createPlaybookRuntime = (options) => {
1442
1578
  }
1443
1579
  const finalState = currentState();
1444
1580
  const failures = [];
1445
- if (actor) {
1446
- actor.stop();
1581
+ stopActor();
1582
+ try {
1583
+ await nestedBridge.dispose();
1584
+ }
1585
+ catch (error) {
1586
+ collectFailure(failures, error);
1447
1587
  }
1448
1588
  try {
1449
1589
  await drainBoundaryCallsAndEmissions();
@@ -1468,6 +1608,7 @@ export const createPlaybookRuntime = (options) => {
1468
1608
  }
1469
1609
  finally {
1470
1610
  playerResumeTokens.clear();
1611
+ playbookCallTurnIds.clear();
1471
1612
  inFlightPlayerIds.clear();
1472
1613
  activeBoundaryCalls.clear();
1473
1614
  activeEmissionCalls.clear();
@@ -1485,7 +1626,7 @@ export const createPlaybookRuntime = (options) => {
1485
1626
  if (failures.length === 1)
1486
1627
  throw failures[0];
1487
1628
  if (failures.length > 1) {
1488
- throw new AggregateError(failures, 'discuss runtime disposal failed');
1629
+ throw new AggregateError(failures, 'decide runtime disposal failed');
1489
1630
  }
1490
1631
  })();
1491
1632
  return disposalPromise;
@@ -1506,10 +1647,10 @@ export const _internal = {
1506
1647
  normalizeErrorCompact,
1507
1648
  normalizeErrorFull,
1508
1649
  DEFAULT_PLAYER_BINDING,
1509
- ALIAS_RESOLUTION,
1510
1650
  STATE_DESCRIPTIONS,
1511
- CAPTAIN_STATES,
1512
- CAPTAIN_STATE_IDS,
1651
+ PLAYER_STATES,
1652
+ PLAYER_STATE_IDS,
1653
+ VERBATIM_PAYLOAD_FIELDS,
1513
1654
  BOSS_INTERRUPT_TARGETS,
1514
1655
  CONTINUATION_PREAMBLE,
1515
1656
  TELEMETRY_TOPIC,