@sublang/playbook 0.9.0 → 1.3.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 (51) hide show
  1. package/README.md +190 -151
  2. package/package.json +50 -6
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
  12. package/reference/sdlc/code.playbook/bin/run.js +999 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
  21. package/reference/sdlc/code.playbook/code.playbook.js +199 -488
  22. package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
  24. package/reference/sdlc/code.playbook/code.registry.js +0 -3
  25. package/reference/sdlc/code.playbook/code.registry.ts +0 -6
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
  27. package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1165 -89
  42. package/slc/optimize.md +92 -0
  43. package/slc/text2gears.md +255 -7
  44. package/src/runtime.d.ts +146 -3
  45. package/src/runtime.ts +201 -2
  46. package/src/xstate-playbook-runtime.d.ts +201 -0
  47. package/src/xstate-playbook-runtime.js +2058 -0
  48. package/src/xstate-playbook-runtime.ts +2792 -0
  49. package/src/xstate-runtime.d.ts +95 -0
  50. package/src/xstate-runtime.js +1258 -0
  51. package/src/xstate-runtime.ts +1816 -0
@@ -11,15 +11,35 @@
11
11
  // alias's first alternative)
12
12
  // Boss event: free-text judge classification
13
13
  // Adjudication: LLM-judge per state
14
- // Contract: PlayerResult / PlaybookPorts / PlaybookRuntime imported
15
- // and re-exported from @sublang/playbook/runtime
14
+ // Contract: PlayerResult / PlaybookPorts / PlaybookSession /
15
+ // PlaybookRuntime imported and re-exported from
16
+ // @sublang/playbook/runtime
16
17
  // (slc/link.md §Output, DR-004 Addendum A4)
18
+ // Runtime: the shared createXStatePlaybookRuntime factory from
19
+ // @sublang/playbook/xstate-runtime interprets the FSM
20
+ // (slc/link.md §Output, DR-019); this module carries only
21
+ // the CODE-specific spec — options validation, player
22
+ // binding, prompt composition, Boss-event classification,
23
+ // and Captain-pane status formatting.
17
24
 
18
- import { createActor, fromPromise } from 'xstate';
25
+ import {
26
+ createPlayerBridge,
27
+ createXStatePlaybookRuntime,
28
+ adjudicatePlayerOutput,
29
+ normalizeError,
30
+ normalizeErrorCompact,
31
+ normalizeErrorFull,
32
+ parseJudgeJson,
33
+ snapshotJsonValue,
34
+ type PlaybookPlayerInput,
35
+ type RuntimeBoundaryCalls,
36
+ type ScheduledStatus,
37
+ type XStatePlaybookRuntimeSpec,
38
+ } from '../../../src/xstate-runtime.js';
19
39
  import {
20
40
  codingMachine,
21
- type CaptainInput,
22
- type CaptainOutput,
41
+ type PlayerInput,
42
+ type PlayerOutput,
23
43
  type CodingEvent,
24
44
  type CodingInput,
25
45
  } from './code.fsm.js';
@@ -29,27 +49,87 @@ import {
29
49
  enumerateRootEvents,
30
50
  } from './code.fsm.introspect.js';
31
51
  import type {
52
+ CaptainCallOptions,
53
+ CaptainResult,
54
+ JsonValue,
55
+ NormalizedError,
56
+ PlayerCallOptions,
57
+ PlaybookCallRequest,
58
+ PlaybookCallResult,
59
+ PlaybookCallStart,
60
+ PlaybookPendingCall,
61
+ PlaybookRunResult,
62
+ PlaybookRuntimeSnapshot,
63
+ PlaybookSession,
64
+ PlaybookState,
65
+ PlaybookStateValue,
66
+ PlaybookTraceEvent,
67
+ PlaybookTraceType,
32
68
  PlaybookPorts,
33
69
  PlaybookRuntime,
70
+ PlaybookRuntimeFactory,
34
71
  PlayerResult,
35
72
  } from '@sublang/playbook/runtime';
36
73
 
37
- // Public contract. `PlayerResult`, `PlaybookPorts`, and `PlaybookRuntime`
38
- // are re-exported from the shared `@sublang/playbook/runtime` module
74
+ // Public contract. `PlayerResult`, `PlaybookPorts`, `PlaybookSession`, and
75
+ // `PlaybookRuntime` are re-exported from the shared runtime module
39
76
  // (slc/link.md §Output, DR-004 Addendum A4) so this playbook and any
40
77
  // future one resolve one contract definition rather than redefining it.
41
78
  // `CodePlaybookOptions` and the default `createPlaybookRuntime` factory
42
79
  // (typed `PlaybookRuntimeFactory<CodePlaybookOptions>`) stay
43
80
  // CODE-specific.
44
- export type { PlayerResult, PlaybookPorts, PlaybookRuntime };
81
+ export type {
82
+ CaptainCallOptions,
83
+ CaptainResult,
84
+ JsonValue,
85
+ NormalizedError,
86
+ PlayerCallOptions,
87
+ PlaybookCallRequest,
88
+ PlaybookCallResult,
89
+ PlaybookCallStart,
90
+ PlaybookPendingCall,
91
+ PlaybookRunResult,
92
+ PlayerResult,
93
+ PlaybookPorts,
94
+ PlaybookSession,
95
+ PlaybookState,
96
+ PlaybookStateValue,
97
+ PlaybookTraceEvent,
98
+ PlaybookTraceType,
99
+ PlaybookRuntime,
100
+ PlaybookRuntimeFactory,
101
+ PlaybookRuntimeSnapshot,
102
+ };
45
103
 
46
104
  export type CodePlaybookOptions = CodingInput;
47
105
 
48
- const BOSS_REPLY_ERRORS = {
49
- missingQuestion: "needsBossReply outcome missing 'question' field",
50
- unregisteredState: (stateId: string) =>
51
- `state ${stateId} declared needsBossReply but is not registered as resumable`,
52
- } as const;
106
+ function snapshotCodePlaybookOptions(value: unknown): CodePlaybookOptions {
107
+ const captured = snapshotJsonValue(value, 'CODE runtime options');
108
+ if (
109
+ captured === null ||
110
+ typeof captured !== 'object' ||
111
+ Array.isArray(captured)
112
+ ) {
113
+ throw new TypeError('CODE runtime options must be an object');
114
+ }
115
+ const record = captured as Readonly<Record<string, JsonValue>>;
116
+ const allowed = new Set([
117
+ 'intent',
118
+ 'irNumber',
119
+ 'coderPlayer',
120
+ 'reviewerPlayer',
121
+ 'committerPlayer',
122
+ ]);
123
+ for (const [key, option] of Object.entries(record)) {
124
+ if (!allowed.has(key)) {
125
+ throw new TypeError(`CODE runtime options.${key} is not declared`);
126
+ }
127
+ if (typeof option !== 'string') {
128
+ throw new TypeError(`CODE runtime options.${key} must be a string`);
129
+ }
130
+ }
131
+ return captured as unknown as CodePlaybookOptions;
132
+ }
53
133
 
54
134
  // Required-payload fields whose value is the player's verbatim long-form
55
135
  // prose. The runtime carries `finalText.trim()` into these fields rather
@@ -61,64 +141,56 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
61
141
  'challenges',
62
142
  ]);
63
143
 
64
- // Normalize an unknown error value to the compact `{ name, message }`
65
- // shape used by Captain-pane / status emissions. Returns `undefined`
66
- // for nullish input so callers can omit absent errors.
67
- function normalizeErrorCompact(
68
- err: unknown,
69
- ): { name: string; message: string } | undefined {
70
- if (err === undefined || err === null) return undefined;
71
- if (err instanceof Error) {
72
- return { name: err.name, message: err.message };
73
- }
74
- if (typeof err === 'object') {
75
- const o = err as Record<string, unknown>;
76
- if (typeof o.message === 'string') {
77
- return {
78
- name: typeof o.name === 'string' ? o.name : 'Error',
79
- message: o.message,
80
- };
81
- }
82
- }
83
- return { name: 'Error', message: String(err) };
84
- }
85
-
86
- // Normalize an unknown error value to the full `{ name, message, stack }`
87
- // shape used by telemetry emissions. Returns `undefined` for nullish
88
- // input. `stack` is omitted when not available on the source value.
89
- function normalizeErrorFull(
90
- err: unknown,
91
- ): { name: string; message: string; stack?: string } | undefined {
92
- const compact = normalizeErrorCompact(err);
93
- if (compact === undefined) return undefined;
94
- if (err instanceof Error) {
95
- return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
96
- }
97
- if (typeof err === 'object' && err !== null) {
98
- const stack = (err as Record<string, unknown>).stack;
99
- if (typeof stack === 'string') {
100
- return { ...compact, stack };
101
- }
102
- }
103
- return compact;
104
- }
105
-
106
144
  // Normalize any `error` field inside a telemetry event so failed
107
145
  // transitions don't leak raw Error instances through the channel.
108
146
  function normalizeEventForTelemetry(event: unknown): unknown {
109
- if (event === null || typeof event !== 'object' || Array.isArray(event)) {
110
- return event;
147
+ if (event === undefined) return undefined;
148
+ return normalizeEventValue(event, 'FSM event', new Set());
149
+ }
150
+
151
+ function normalizeEventValue(
152
+ value: unknown,
153
+ path: string,
154
+ ancestors: ReadonlySet<object>,
155
+ ): JsonValue {
156
+ if (Array.isArray(value)) return snapshotJsonValue(value, path);
157
+ if (value === null || typeof value !== 'object') {
158
+ return snapshotJsonValue(value, path);
159
+ }
160
+ if (ancestors.has(value)) {
161
+ throw new TypeError(`${path} must not contain a JSON cycle`);
162
+ }
163
+ const prototype = Object.getPrototypeOf(value) as unknown;
164
+ if (prototype !== Object.prototype && prototype !== null) {
165
+ return snapshotJsonValue(value, path);
166
+ }
167
+ if (Object.getOwnPropertySymbols(value).length > 0) {
168
+ return snapshotJsonValue(value, path);
169
+ }
170
+ const nextAncestors = new Set(ancestors).add(value);
171
+ const normalized: Record<string, JsonValue> = {};
172
+ for (const [key, descriptor] of Object.entries(
173
+ Object.getOwnPropertyDescriptors(value),
174
+ )) {
175
+ if (!descriptor.enumerable) {
176
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
177
+ }
178
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
179
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
180
+ }
181
+ if (descriptor.value === undefined) continue;
182
+ normalized[key] =
183
+ key === 'error'
184
+ ? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
185
+ : normalizeEventValue(
186
+ descriptor.value,
187
+ `${path}.${key}`,
188
+ nextAncestors,
189
+ );
111
190
  }
112
- const e = event as Record<string, unknown>;
113
- if (!('error' in e)) return event;
114
- const normalized = normalizeErrorFull(e.error);
115
- return { ...e, error: normalized };
191
+ return snapshotJsonValue(normalized, path);
116
192
  }
117
193
 
118
- // Internal capabilities (DR-004 §10). Each ships with its final
119
- // signature; behavior lands in the per-capability task noted by the
120
- // TODO marker.
121
-
122
194
  // Player-prompt composer — DR-004 §6.
123
195
  // Substitutes the three placeholder tokens in `input.prompt` (literal
124
196
  // string replace, no escaping) and arranges labelled blocks around
@@ -131,7 +203,7 @@ function normalizeEventForTelemetry(event: unknown): unknown {
131
203
  // reply, the continuation preamble and Q/A blocks precede every
132
204
  // ordinary block. The FSM's prompt body is never re-flowed.
133
205
 
134
- function composePlayerPrompt(input: CaptainInput): string {
206
+ function composePlayerPrompt(input: PlayerInput): string {
135
207
  const blocks: string[] = [];
136
208
  if (
137
209
  input.pendingBossQuestion !== undefined &&
@@ -189,7 +261,7 @@ function composePlayerPrompt(input: CaptainInput): string {
189
261
  // set. The alias selects only the host pane; it is not a PBRT-4
190
262
  // identity string, so it leaves <coder-llm> / <reviewer-llm>
191
263
  // untouched and `input.player` stays `Committer` (PLAYBOOK-3).
192
- function resolvePlayerId(input: CaptainInput): string {
264
+ function resolvePlayerId(input: PlayerInput): string {
193
265
  switch (input.player) {
194
266
  case 'Coder':
195
267
  return 'coder';
@@ -202,69 +274,9 @@ function resolvePlayerId(input: CaptainInput): string {
202
274
  return 'coder';
203
275
  default: {
204
276
  const exhaustive: never = input.player;
205
- throw new Error(
206
- `resolvePlayerId: unknown player ${String(exhaustive)}`,
207
- );
208
- }
209
- }
210
- }
211
-
212
- // LLM judge — DR-004 §4. Builds a prompt that lists each declared
213
- // outcome verbatim, asks ports.callJudge for a JSON
214
- // `{ guard, …payloadFields }` response, and returns the parsed
215
- // object once the chosen guard is one of the input.result keys.
216
- // Adjudicator failures (malformed JSON, missing/unknown guard) are
217
- // control-plane errors and propagate via throw per slc/link.md.
218
- async function adjudicate(
219
- input: CaptainInput,
220
- finalText: string,
221
- ports: PlaybookPorts,
222
- signal: AbortSignal,
223
- ): Promise<CaptainOutput> {
224
- const prompt = buildJudgePrompt(input, finalText);
225
- const raw = await ports.callJudge(prompt, signal);
226
- const parsed = parseJudgeJson(raw);
227
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
228
- throw new Error('adjudicate: judge response is not a JSON object');
229
- }
230
- const obj = parsed as Record<string, unknown>;
231
- const guard = obj.guard;
232
- if (typeof guard !== 'string') {
233
- throw new Error('adjudicate: judge response missing string "guard" field');
234
- }
235
- if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
236
- throw new Error(
237
- `adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(
238
- input.result,
239
- ).join(', ')}`,
240
- );
241
- }
242
- // Per slc/link.md, a missing payload field the state's `result`
243
- // description requires is a control-plane error. The FSM names
244
- // required fields with the literal phrase
245
- // Output shall include `<fieldName>: <...>`
246
- // so we extract those tokens and require each to be a string in
247
- // the judge response — except for VERBATIM_PAYLOAD_FIELDS
248
- // (`reviews`, `challenges`), where the runtime substitutes
249
- // `finalText.trim()` so the long-form prose is not round-tripped
250
- // through judge JSON. Short extracted fields like `question` and
251
- // `taskDescription` keep the existing extract-and-validate path.
252
- const verbatim = finalText.trim();
253
- for (const field of extractRequiredFields(input.result[guard])) {
254
- if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
255
- obj[field] = verbatim;
256
- continue;
257
- }
258
- if (typeof obj[field] !== 'string') {
259
- if (guard === 'needsBossReply' && field === 'question') {
260
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
261
- }
262
- throw new Error(
263
- `adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
264
- );
277
+ throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
265
278
  }
266
279
  }
267
- return obj as CaptainOutput;
268
280
  }
269
281
 
270
282
  function extractRequiredFields(description: string): string[] {
@@ -276,7 +288,7 @@ function extractRequiredFields(description: string): string[] {
276
288
  return fields;
277
289
  }
278
290
 
279
- function buildJudgePrompt(input: CaptainInput, finalText: string): string {
291
+ function buildJudgePrompt(input: PlayerInput, finalText: string): string {
280
292
  const lines: string[] = [];
281
293
  lines.push(`The ${input.player} just produced this output:`);
282
294
  lines.push('');
@@ -299,137 +311,38 @@ function buildJudgePrompt(input: CaptainInput, finalText: string): string {
299
311
  return lines.join('\n');
300
312
  }
301
313
 
302
- // Judge replies are meant to be a single JSON object, but LLMs
303
- // routinely wrap them in prose ("Here is the result: …"), Markdown
304
- // code fences, or trailing commentary, and occasionally emit a
305
- // trailing comma or truncate the tail (a dropped closing brace, an
306
- // unterminated string). parseJudgeJson is deliberately lenient: it
307
- // first tries a strict parse of the (optionally fenced) body, then
308
- // scans every `{`/`[` as a possible start in document order and
309
- // returns the first recoverable object. At each start it prefers a
310
- // strict balanced span and falls back to a repaired (trailing-comma /
311
- // truncation) span, so a damaged object earlier in the prose is not
312
- // overridden by a cleaner one later. Scanning every start (not just
313
- // the first bracket) keeps a bracketed fragment in surrounding prose —
314
- // e.g. an aside like `see [1]` or `{n/a}` before the real object —
315
- // from masking a later, genuinely valid object. Both callers
316
- // (classification and adjudication) expect an object, so plain objects
317
- // win over arrays/scalars; the first value of any shape is remembered
318
- // so a legitimately array/scalar reply still surfaces to the caller's
319
- // own object check. Only a reply from which no JSON value can be
320
- // recovered is treated as malformed and throws, preserving the
321
- // control-plane error contract (PBRT-7, PBRT-10).
322
- function parseJudgeJson(raw: string): unknown {
323
- const fenced = stripCodeFence(raw.trim());
324
- // Fast path: a well-formed (optionally fenced) JSON body.
325
- try {
326
- return JSON.parse(fenced);
327
- } catch {
328
- // Fall through to lenient extraction + repair.
329
- }
330
- const starts: number[] = [];
331
- for (let i = 0; i < fenced.length; i++) {
332
- const ch = fenced[i];
333
- if (ch === '{' || ch === '[') starts.push(i);
334
- }
335
- // Walk starts in document order. At each start prefer a strict
336
- // balanced span (most trustworthy) and fall back to a repaired one
337
- // for a trailing-comma / truncated tail, so the earliest intended
338
- // object wins even when it needs repair. Return the first plain
339
- // object; remember the first value of any shape so a legitimately
340
- // array/scalar reply still surfaces to the caller's own object check.
341
- let firstValue: { value: unknown } | undefined;
342
- for (const start of starts) {
343
- let parsedHere: { value: unknown } | undefined;
344
- for (const repair of [false, true]) {
345
- const candidate = extractJsonValue(fenced, start, repair);
346
- if (candidate === undefined) continue;
347
- try {
348
- parsedHere = { value: JSON.parse(candidate) };
349
- } catch {
350
- continue; // not parseable this way — try repair, then next start
351
- }
352
- break; // prefer the strict span at this start over its repair
353
- }
354
- if (parsedHere === undefined) continue;
355
- if (isPlainObject(parsedHere.value)) return parsedHere.value;
356
- if (firstValue === undefined) firstValue = parsedHere;
357
- }
358
- if (firstValue !== undefined) return firstValue.value;
359
- throw new Error('adjudicate: judge response is not valid JSON');
360
- }
361
-
362
- function isPlainObject(value: unknown): boolean {
363
- return typeof value === 'object' && value !== null && !Array.isArray(value);
364
- }
365
-
366
- // Strip a single Markdown code fence that wraps the whole string.
367
- function stripCodeFence(text: string): string {
368
- const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
369
- return fence ? fence[1].trim() : text;
370
- }
371
-
372
- // Scan from `start` (a `{`/`[` index), tracking string and
373
- // bracket-nesting state, and emit the balanced JSON value rooted
374
- // there. Anything after the top-level value closes is ignored (so a
375
- // trailing code fence or commentary does not matter). With
376
- // `repair === false` the span is returned only if it actually closes,
377
- // and trailing commas are left intact — so the caller can prefer a
378
- // cleanly-balanced span before attempting repair; if input ends
379
- // before the value closes, undefined is returned. With
380
- // `repair === true` common damage is fixed: a trailing comma before a
381
- // close is removed, an unterminated string is closed, and any
382
- // brackets still open at end-of-input are closed in order.
383
- function extractJsonValue(
384
- text: string,
385
- start: number,
386
- repair: boolean,
387
- ): string | undefined {
388
- const stack: string[] = [];
389
- let out = '';
390
- let inString = false;
391
- let escaped = false;
392
- for (let i = start; i < text.length; i++) {
393
- const ch = text[i];
394
- if (inString) {
395
- out += ch;
396
- if (escaped) escaped = false;
397
- else if (ch === '\\') escaped = true;
398
- else if (ch === '"') inString = false;
399
- continue;
400
- }
401
- if (ch === '"') {
402
- inString = true;
403
- out += ch;
404
- continue;
405
- }
406
- if (ch === '{' || ch === '[') {
407
- stack.push(ch === '{' ? '}' : ']');
408
- out += ch;
409
- continue;
410
- }
411
- if (ch === '}' || ch === ']') {
412
- if (repair) out = dropTrailingComma(out);
413
- out += ch;
414
- stack.pop();
415
- if (stack.length === 0) return out; // top-level value complete
416
- continue;
417
- }
418
- out += ch;
419
- }
420
- // End of input before the top-level value closed.
421
- if (!repair) return undefined; // strict pass: no balanced span here
422
- if (inString) out += '"';
423
- out = dropTrailingComma(out);
424
- while (stack.length > 0) out += stack.pop();
425
- return out;
426
- }
314
+ // CODE-specific adjudication strategy: the CODE judge prompt above, the
315
+ // DR-004 `Output shall include` required-field extraction, and the
316
+ // verbatim long-form payload fields.
317
+ const CODE_ADJUDICATION = {
318
+ buildJudgePrompt: (input: PlaybookPlayerInput, finalText: string) =>
319
+ buildJudgePrompt(input as unknown as PlayerInput, finalText),
320
+ extractRequiredFields,
321
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
322
+ };
427
323
 
428
- // Remove a trailing comma (and any whitespace after it) at the end of
429
- // the accumulated output, so `{"a":1,}` / `[1,2,]` and truncated
430
- // `{"a":1,` repair to valid JSON.
431
- function dropTrailingComma(out: string): string {
432
- return out.replace(/,(\s*)$/, '$1');
324
+ // LLM judge DR-004 §4. Delegates to the shared adjudicator with the
325
+ // CODE strategy: it lists each declared outcome verbatim, asks
326
+ // ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
327
+ // returns the parsed object once the chosen guard is one of the
328
+ // input.result keys. Adjudicator failures (malformed JSON,
329
+ // missing/unknown guard) are control-plane errors and propagate via
330
+ // throw per slc/link.md.
331
+ async function adjudicate(
332
+ input: PlayerInput,
333
+ finalText: string,
334
+ ports: PlaybookPorts,
335
+ signal: AbortSignal,
336
+ boundary?: RuntimeBoundaryCalls,
337
+ ): Promise<PlayerOutput> {
338
+ return (await adjudicatePlayerOutput(
339
+ CODE_ADJUDICATION,
340
+ input,
341
+ finalText,
342
+ ports,
343
+ signal,
344
+ boundary,
345
+ )) as unknown as PlayerOutput;
433
346
  }
434
347
 
435
348
  // Boss-event classifier — DR-004 §3.
@@ -444,11 +357,12 @@ async function classifyBossText(
444
357
  ports: PlaybookPorts,
445
358
  signal: AbortSignal,
446
359
  snapshotOrState?: unknown,
360
+ boundary?: RuntimeBoundaryCalls,
447
361
  ): Promise<CodingEvent | undefined> {
448
362
  const trimmed = text.trim();
449
363
  if (trimmed === '') return undefined;
450
364
 
451
- return classifyWithLlm(text, ports, signal, snapshotOrState);
365
+ return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
452
366
  }
453
367
 
454
368
  // JumpableStateId is internal to code.fsm.ts (not exported), so
@@ -469,10 +383,19 @@ async function classifyWithLlm(
469
383
  ports: PlaybookPorts,
470
384
  signal: AbortSignal,
471
385
  snapshotOrState?: unknown,
386
+ boundary?: RuntimeBoundaryCalls,
472
387
  ): Promise<CodingEvent | undefined> {
473
388
  const state = classifierState(snapshotOrState);
474
389
  const prompt = buildClassifierPrompt(text, state);
475
- const raw = await ports.callJudge(prompt, signal);
390
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
391
+ const raw = boundary
392
+ ? await boundary.callJudge(
393
+ 'boss-input-classification',
394
+ stateId,
395
+ prompt,
396
+ signal,
397
+ )
398
+ : await ports.callJudge(prompt, signal);
476
399
  let parsed: unknown;
477
400
  try {
478
401
  parsed = parseJudgeJson(raw);
@@ -562,7 +485,36 @@ async function classifyWithLlm(
562
485
  await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
563
486
  return undefined;
564
487
  }
565
- return { type: 'BOSS_REPLY', answer: payload.answer };
488
+ const pending = pendingBossQuestionFromContext(state.context);
489
+ if (!pending) {
490
+ await ports.emitStatus(
491
+ 'Classifier returned BOSS_REPLY without a pending question',
492
+ );
493
+ return undefined;
494
+ }
495
+ if (
496
+ payload.questionId !== undefined &&
497
+ typeof payload.questionId !== 'string'
498
+ ) {
499
+ await ports.emitStatus(
500
+ 'Classifier supplied a non-string questionId for BOSS_REPLY',
501
+ );
502
+ return undefined;
503
+ }
504
+ if (
505
+ typeof payload.questionId === 'string' &&
506
+ payload.questionId !== pending.questionId
507
+ ) {
508
+ await ports.emitStatus(
509
+ `Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`,
510
+ );
511
+ return undefined;
512
+ }
513
+ return {
514
+ type: 'BOSS_REPLY',
515
+ answer: payload.answer,
516
+ questionId: pending.questionId,
517
+ };
566
518
  }
567
519
  default:
568
520
  await ports.emitStatus(
@@ -602,7 +554,9 @@ function classifierState(snapshotOrState: unknown): ClassifierState {
602
554
 
603
555
  function buildClassifierPrompt(text: string, state: ClassifierState): string {
604
556
  const currentState =
605
- typeof state.value === 'string' ? state.value : 'unknown';
557
+ typeof state.value === 'string'
558
+ ? state.value
559
+ : JSON.stringify(state.value ?? null);
606
560
  const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
607
561
  const lines = [
608
562
  'Classify the following Boss message into exactly one of these events.',
@@ -613,8 +567,9 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
613
567
  ];
614
568
  if (pendingBossQuestion !== undefined) {
615
569
  lines.push(
570
+ `Pending question id: ${pendingBossQuestion.questionId}`,
571
+ `Pending asking player: ${pendingBossQuestion.player}`,
616
572
  `Pending Boss question: ${pendingBossQuestion.question}`,
617
- `Pending resume state: ${pendingBossQuestion.resumeStateId}`,
618
573
  );
619
574
  }
620
575
  lines.push(
@@ -630,27 +585,25 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
630
585
  lines.push(` - ${target.stateId}: ${target.description}`);
631
586
  }
632
587
  if (currentState === 'awaitBossReply') {
633
- lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
588
+ lines.push(
589
+ '- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }',
590
+ );
634
591
  } else {
635
592
  lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
636
593
  }
637
- lines.push(
638
- '',
639
- 'Boss message:',
640
- '```',
641
- text,
642
- '```',
643
- );
594
+ lines.push('', 'Boss message:', '```', text, '```');
644
595
  return lines.join('\n');
645
596
  }
646
597
 
647
- // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
648
- // codingMachine invokes from every captain-invoking state. Per turn:
649
- // resolve playerId, compose the player prompt, await
650
- // ports.callPlayer, adjudicate the finalText. PlayerResult status of
651
- // 'aborted' or 'error' throws so XState routes via onError → #failed
652
- // (the single fail-stop sink for both Captain errors and player
653
- // failures).
598
+ // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
599
+ // codingMachine invokes from every player-invoking state, built by the
600
+ // shared createPlayerBridge with the CODE binding, composer, and
601
+ // adjudication strategy. Per turn: resolve playerId, compose the player
602
+ // prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
603
+ // status of 'aborted' or 'error' throws so XState routes via onError →
604
+ // #failed (the single fail-stop sink for both Captain errors and player
605
+ // failures). Captain remains the orchestrator and adjudicator; it is not
606
+ // encoded as the delegated FSM actor.
654
607
  //
655
608
  // `getActiveSignal` is the runtime's hook for flowing the Boss's
656
609
  // `handleBossInput.signal` into the host port calls — fromPromise
@@ -660,33 +613,22 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
660
613
  function captainBridge(
661
614
  ports: PlaybookPorts,
662
615
  getActiveSignal?: () => AbortSignal | undefined,
616
+ boundary?: RuntimeBoundaryCalls,
617
+ onControlPlaneError?: (error: unknown) => void,
663
618
  ) {
664
- return fromPromise<CaptainOutput, CaptainInput>(
665
- async ({ input, signal }) => {
666
- const activeSignal = getActiveSignal?.() ?? signal;
667
- const playerId = resolvePlayerId(input);
668
- const prompt = composePlayerPrompt(input);
669
- const result = await ports.callPlayer(playerId, prompt, activeSignal);
670
- if (result.status !== 'ok') {
671
- throw new Error(
672
- result.error ??
673
- `captainBridge: callPlayer status "${result.status}"`,
674
- );
675
- }
676
- if (result.finalText === undefined) {
677
- throw new Error(
678
- 'captainBridge: callPlayer returned status=ok with no finalText',
679
- );
680
- }
681
- const output = await adjudicate(
682
- input,
683
- result.finalText,
684
- ports,
685
- activeSignal,
686
- );
687
- validateBossReplyOutput(input, output);
688
- return output;
619
+ return createPlayerBridge(
620
+ {
621
+ resolvePlayerId: (input) =>
622
+ resolvePlayerId(input as unknown as PlayerInput),
623
+ composePlayerPrompt: (input) =>
624
+ composePlayerPrompt(input as unknown as PlayerInput),
625
+ adjudication: CODE_ADJUDICATION,
626
+ resumableStateIds: registeredResumableStateIds,
689
627
  },
628
+ ports,
629
+ getActiveSignal,
630
+ boundary,
631
+ onControlPlaneError,
690
632
  );
691
633
  }
692
634
 
@@ -696,7 +638,7 @@ function captainBridge(
696
638
  // glance:
697
639
  // (no glyph) bare FSM event type — host renders as captain speech
698
640
  // (e.g., `captain> START_CODING`)
699
- // ⤷ captain-invoking state entry: `<Player>: <label>`
641
+ // ⤷ player-invoking state entry: `<Player>: <label>`
700
642
  // → transition guard outcome (`· field=N` tallies
701
643
  // appended); the host presenter owns any visual
702
644
  // nesting under the preceding ⤷ entry
@@ -735,7 +677,7 @@ const STATE_LABELS: Readonly<Record<string, string>> = {
735
677
  };
736
678
 
737
679
  interface StateMetadata {
738
- player: CaptainInput['player'];
680
+ player: PlayerInput['player'];
739
681
  sourceItem: string;
740
682
  label: string;
741
683
  }
@@ -746,7 +688,7 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
746
688
  const label = STATE_LABELS[s.stateId];
747
689
  if (!label) {
748
690
  throw new Error(
749
- `code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`,
691
+ `code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`,
750
692
  );
751
693
  }
752
694
  const input = s.getInput({});
@@ -755,35 +697,12 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
755
697
  return m;
756
698
  })();
757
699
 
758
- const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
759
- [...stateMetadata.entries()].map(([stateId, meta]) => [
760
- meta.sourceItem,
761
- stateId,
762
- ]),
763
- );
764
-
765
700
  const registeredResumableStateIds: ReadonlySet<string> = new Set(
766
701
  enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
767
702
  (transition) => transition.target,
768
703
  ),
769
704
  );
770
705
 
771
- function validateBossReplyOutput(
772
- input: CaptainInput,
773
- output: CaptainOutput,
774
- ): void {
775
- if (output.guard !== 'needsBossReply') return;
776
- if (typeof output.question !== 'string') {
777
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
778
- }
779
- const stateId = stateIdBySourceItem.get(input.sourceItem);
780
- if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
781
- throw new Error(
782
- BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
783
- );
784
- }
785
- }
786
-
787
706
  const QUIESCENT_STATES: ReadonlySet<string> = new Set([
788
707
  'ready',
789
708
  'awaitBossReply',
@@ -795,12 +714,9 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
795
714
  // pane per PBRT-3: the readline returning to its `boss>` prompt is
796
715
  // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
797
716
  // tombstone is redundant.
798
- const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
799
- 'ready',
800
- 'done',
801
- ]);
717
+ const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
802
718
 
803
- // Captain-pane surface (PBRT-3): every captain-invoking state plus
719
+ // Captain-pane surface (PBRT-3): every player-invoking state plus
804
720
  // the quiescent states whose entry still carries information
805
721
  // (failure with `lastError`, awaitBossReply with the pending
806
722
  // question). `ready` and `done` flow through the inspect handler
@@ -811,7 +727,12 @@ const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
811
727
  ...QUIESCENT_STATES,
812
728
  ]);
813
729
 
730
+ type BossReplyQuestionId = NonNullable<
731
+ Extract<CodingEvent, { type: 'BOSS_REPLY' }>['questionId']
732
+ >;
733
+
814
734
  interface PendingBossQuestionForStatus {
735
+ questionId: BossReplyQuestionId;
815
736
  resumeStateId: string;
816
737
  sourceItem: string;
817
738
  player: string;
@@ -830,6 +751,7 @@ function pendingBossQuestionFromContext(
830
751
  >;
831
752
  if (
832
753
  typeof candidate.resumeStateId !== 'string' ||
754
+ typeof candidate.questionId !== 'string' ||
833
755
  typeof candidate.sourceItem !== 'string' ||
834
756
  typeof candidate.player !== 'string' ||
835
757
  typeof candidate.question !== 'string'
@@ -837,6 +759,7 @@ function pendingBossQuestionFromContext(
837
759
  return undefined;
838
760
  }
839
761
  return {
762
+ questionId: candidate.questionId as BossReplyQuestionId,
840
763
  resumeStateId: candidate.resumeStateId,
841
764
  sourceItem: candidate.sourceItem,
842
765
  player: candidate.player,
@@ -864,9 +787,7 @@ function formatAwaitBossReplyQuestion(
864
787
  // It carries only the resume target, asking player, and source item;
865
788
  // the former `q="<first 80 chars>"` excerpt rider is dropped now that
866
789
  // the full question rides the captain-speech line above.
867
- function formatAwaitBossReplyMarker(
868
- context: Record<string, unknown>,
869
- ): string {
790
+ function formatAwaitBossReplyMarker(context: Record<string, unknown>): string {
870
791
  const pending = pendingBossQuestionFromContext(context);
871
792
  const resumeStateId = pending?.resumeStateId ?? 'unknown';
872
793
  const player = pending?.player ?? 'unknown';
@@ -912,9 +833,9 @@ function stateTelemetryPayload(
912
833
  context: Record<string, unknown>,
913
834
  ): Record<string, unknown> {
914
835
  const payload: Record<string, unknown> = {
915
- from,
836
+ from: from ?? null,
916
837
  to,
917
- event: normalizeEventForTelemetry(event),
838
+ event: normalizeEventForTelemetry(event) ?? null,
918
839
  };
919
840
  if (to === 'awaitBossReply') {
920
841
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
@@ -931,10 +852,47 @@ function stateTelemetryPayload(
931
852
  return payload;
932
853
  }
933
854
 
855
+ // Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
856
+ // the `→ guard` outcome line for the settling transition, then either
857
+ // the awaitBossReply question + rider-less marker pair or the state's
858
+ // entry line (with `lastError` data on `failed`).
859
+ function statusesForState(
860
+ state: PlaybookState,
861
+ context: Record<string, unknown>,
862
+ event: unknown,
863
+ ): ScheduledStatus[] {
864
+ const to = state.stateId;
865
+ if (to === undefined || !CAPTAIN_PANE_STATES.has(to)) return [];
866
+ const statuses: ScheduledStatus[] = [];
867
+ const transitionLine = formatTransition(event);
868
+ if (transitionLine !== undefined) {
869
+ statuses.push({ message: transitionLine });
870
+ }
871
+ if (to === 'awaitBossReply') {
872
+ statuses.push(
873
+ { message: formatAwaitBossReplyQuestion(context) },
874
+ { message: formatAwaitBossReplyMarker(context) },
875
+ );
876
+ } else {
877
+ const entryLine = formatStateEntry(to);
878
+ if (entryLine !== undefined) {
879
+ const lastError =
880
+ to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
881
+ statuses.push({
882
+ message: entryLine,
883
+ ...(lastError === undefined
884
+ ? {}
885
+ : {
886
+ data: snapshotJsonValue({ lastError }, 'failed status data'),
887
+ }),
888
+ });
889
+ }
890
+ }
891
+ return statuses;
892
+ }
893
+
934
894
  // Internal export surface for tests. Not part of the stable public API;
935
- // the leading underscore signals "subject to change." Each member is
936
- // referenced here so `noUnusedLocals` stays clean while later tasks
937
- // wire the factory body to use them.
895
+ // the leading underscore signals "subject to change."
938
896
  export const _internal = {
939
897
  composePlayerPrompt,
940
898
  resolvePlayerId,
@@ -956,228 +914,31 @@ export const _internal = {
956
914
  VERBATIM_PAYLOAD_FIELDS,
957
915
  };
958
916
 
959
- export default function createPlaybookRuntime(
960
- options: CodePlaybookOptions,
961
- ): PlaybookRuntime {
962
- let actor: ReturnType<typeof createActor> | undefined;
963
- let savedPorts: PlaybookPorts | undefined;
964
- // The Boss's per-turn AbortSignal, surfaced to captainBridge so
965
- // ports.callPlayer / callJudge see the right cancellation source.
966
- // null between turns; set by handleBossInput.
967
- let activeSignal: AbortSignal | undefined;
968
- // Previous root-machine state for the inspect-driven telemetry /
969
- // status emitter. undefined before the first inspect firing.
970
- let priorState: unknown;
971
-
972
- // Emission queue. slc/link.md says emissions "shall be ordered,
973
- // awaited, and never-dropped"; subscribe/inspect callbacks are
974
- // synchronous and can't await, so each emit is enqueued and a
975
- // single drainer processes them sequentially.
976
- const emitQueue: Array<() => Promise<void>> = [];
977
- let drainer: Promise<void> | undefined;
978
-
979
- function enqueueEmit(fn: () => Promise<void>): void {
980
- emitQueue.push(fn);
981
- if (!drainer) {
982
- drainer = (async () => {
983
- while (emitQueue.length > 0) {
984
- try {
985
- await emitQueue.shift()!();
986
- } catch {
987
- // Suppress host-side emission errors; the control plane
988
- // surfaces real failures via handleBossInput throws.
989
- }
990
- }
991
- drainer = undefined;
992
- })();
993
- }
994
- }
995
-
996
- function drainEmissions(): Promise<void> {
997
- return drainer ?? Promise.resolve();
998
- }
999
-
1000
- function buildActor(
1001
- ports: PlaybookPorts,
1002
- ): ReturnType<typeof createActor> {
1003
- priorState = undefined;
1004
- return createActor(
1005
- codingMachine.provide({
1006
- actors: { captain: captainBridge(ports, () => activeSignal) },
1007
- }),
1008
- {
1009
- input: options,
1010
- inspect: (inspectionEvent) => {
1011
- if (inspectionEvent.type !== '@xstate.snapshot') return;
1012
- const snap = inspectionEvent.snapshot as {
1013
- value?: unknown;
1014
- context?: Record<string, unknown>;
1015
- };
1016
- // Filter out captain sub-actor (fromPromise) snapshots —
1017
- // only the root codingMachine snapshot has a string value.
1018
- if (typeof snap.value !== 'string') return;
1019
- const to = snap.value;
1020
- if (priorState === to) return;
1021
- const from = priorState;
1022
- priorState = to;
1023
- // Telemetry on every transition (PBRT-14).
1024
- const context = snap.context ?? {};
1025
- enqueueEmit(() =>
1026
- ports.emitTelemetry({
1027
- topic: 'playbook.fsm.state',
1028
- payload: stateTelemetryPayload(
1029
- from,
1030
- to,
1031
- inspectionEvent.event,
1032
- context,
1033
- ),
1034
- }),
1035
- );
1036
- // Captain pane (PBRT-3 / PBRT-14): show the transition
1037
- // guard first (when this is an actor-done transition with
1038
- // a known guard), then the new state entry, then any
1039
- // context riders the entering state cares about. Terminal
1040
- // entry to `failed` carries `lastError` as the data arg.
1041
- if (!CAPTAIN_PANE_STATES.has(to)) return;
1042
- const transitionLine = formatTransition(inspectionEvent.event);
1043
- if (transitionLine !== undefined) {
1044
- enqueueEmit(() => ports.emitStatus(transitionLine));
1045
- }
1046
- // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
1047
- // full player question as captain speech, then the rider-less
1048
- // routing marker. The full-question telemetry rides
1049
- // stateTelemetryPayload above.
1050
- if (to === 'awaitBossReply') {
1051
- const questionLine = formatAwaitBossReplyQuestion(context);
1052
- const markerLine = formatAwaitBossReplyMarker(context);
1053
- enqueueEmit(() => ports.emitStatus(questionLine));
1054
- enqueueEmit(() => ports.emitStatus(markerLine));
1055
- return;
1056
- }
1057
- const entryLine = formatStateEntry(to);
1058
- if (entryLine === undefined) return;
1059
- if (to === 'failed') {
1060
- const lastError = (snap.context as { lastError?: unknown })
1061
- ?.lastError;
1062
- const data = { lastError: normalizeErrorCompact(lastError) };
1063
- enqueueEmit(() => ports.emitStatus(entryLine, data));
1064
- } else {
1065
- enqueueEmit(() => ports.emitStatus(entryLine));
1066
- }
1067
- },
1068
- },
1069
- );
1070
- }
1071
-
1072
- const runtime = {
1073
- async init(ports: PlaybookPorts): Promise<void> {
1074
- savedPorts = ports;
1075
- actor = buildActor(ports);
1076
- actor.start();
1077
- await drainEmissions();
1078
- },
1079
-
1080
- async handleBossInput({
1081
- text,
1082
- signal,
1083
- }: {
1084
- text: string;
1085
- signal: AbortSignal;
1086
- }): Promise<void> {
1087
- if (!actor || !savedPorts) {
1088
- throw new Error(
1089
- 'createPlaybookRuntime.handleBossInput: init must be called first',
1090
- );
1091
- }
1092
- activeSignal = signal;
1093
- try {
1094
- // 1. Classify non-empty text into an FSM event through the judge.
1095
- const event = await classifyBossText(
1096
- text,
1097
- savedPorts,
1098
- signal,
1099
- actor.getSnapshot(),
1100
- );
1101
- // Empty input, no-action classifier output, or invalid classifier
1102
- // output — nothing to send.
1103
- if (event === undefined) {
1104
- await drainEmissions();
1105
- return;
1106
- }
1107
- // 2. Captain-pane classification line (PBRT-14): the bare
1108
- // FSM event type, emitted before the FSM advances so the
1109
- // host can render it as captain speech (e.g.,
1110
- // `captain> START_CODING`). Enqueued so it interleaves
1111
- // cleanly with the inspect-driven transition emissions.
1112
- const echoPorts = savedPorts;
1113
- enqueueEmit(() =>
1114
- echoPorts.emitStatus(formatClassification(event.type)),
1115
- );
1116
- // 3. final state ('done') cannot accept new events — dispose
1117
- // and reconstruct per DR-004 §5.
1118
- if (actor.getSnapshot().status === 'done') {
1119
- actor.stop();
1120
- actor = buildActor(savedPorts);
1121
- actor.start();
1122
- }
1123
- // 4. Send the event.
1124
- actor.send(event);
1125
- // 5. Drive to quiescence. On signal-abort we take no FSM
1126
- // action: the captain bridge's awaited callPlayer rejects
1127
- // naturally, the bridge throws, XState routes through
1128
- // onError → #failed, and this loop sees the quiescent
1129
- // snapshot and returns (DR-004 §8 natural rejection).
1130
- await driveToQuiescence(actor);
1131
- // Drain transition emissions before returning so the Boss
1132
- // sees the final status line for this turn.
1133
- await drainEmissions();
1134
- } finally {
1135
- activeSignal = undefined;
1136
- }
1137
- },
1138
-
1139
- async dispose(): Promise<void> {
1140
- if (actor) {
1141
- actor.stop();
1142
- actor = undefined;
1143
- }
1144
- // Drain any in-flight emissions per slc/link.md §Session
1145
- // lifecycle ("stop the actor and drain pending port emissions").
1146
- await drainEmissions();
1147
- savedPorts = undefined;
1148
- },
1149
-
1150
- // @internal — test-only escape hatch for inspecting the
1151
- // underlying actor's snapshot. Most state assertions are now
1152
- // expressible via the recorded emitStatus / emitTelemetry
1153
- // calls (DR-004 §9); the hatch stays for the few cases where
1154
- // direct context inspection is clearer (e.g., the dispose
1155
- // teardown test).
1156
- _getActor() {
1157
- return actor;
1158
- },
1159
- };
1160
- return runtime as PlaybookRuntime;
1161
- }
917
+ // The CODE-specific spec handed to the shared runtime factory
918
+ // (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
919
+ // boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
920
+ // DR-014 parked-session snapshot capability — lives in
921
+ // @sublang/playbook/xstate-runtime; this spec carries only what is
922
+ // CODE-specific.
923
+ const runtimeSpec: XStatePlaybookRuntimeSpec<CodePlaybookOptions> = {
924
+ label: 'CODE',
925
+ snapshotOptions: snapshotCodePlaybookOptions,
926
+ resolvePlayerId: (input) => resolvePlayerId(input as unknown as PlayerInput),
927
+ composePlayerPrompt: (input) =>
928
+ composePlayerPrompt(input as unknown as PlayerInput),
929
+ buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
930
+ extractRequiredFields,
931
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
932
+ resumableStateIds: registeredResumableStateIds,
933
+ classifyBossText: (text, ports, signal, snapshotOrState, boundary) =>
934
+ classifyBossText(text, ports, signal, snapshotOrState, boundary),
935
+ classificationStatus: (event) => formatClassification(event.type),
936
+ statusesForState,
937
+ normalizeTransitionEvent: (event) =>
938
+ normalizeEventForTelemetry(event) as JsonValue | undefined,
939
+ };
1162
940
 
1163
- function driveToQuiescence(
1164
- actor: ReturnType<typeof createActor>,
1165
- ): Promise<void> {
1166
- return new Promise<void>((resolve) => {
1167
- if (isQuiescent(actor.getSnapshot())) {
1168
- resolve();
1169
- return;
1170
- }
1171
- const sub = actor.subscribe((snap) => {
1172
- if (isQuiescent(snap)) {
1173
- sub.unsubscribe();
1174
- resolve();
1175
- }
1176
- });
1177
- });
1178
- }
941
+ const createPlaybookRuntime: PlaybookRuntimeFactory<CodePlaybookOptions> =
942
+ createXStatePlaybookRuntime(codingMachine, runtimeSpec);
1179
943
 
1180
- function isQuiescent(snap: { value: unknown }): boolean {
1181
- const v = snap.value;
1182
- return typeof v === 'string' && QUIESCENT_STATES.has(v);
1183
- }
944
+ export default createPlaybookRuntime;