@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,16 +11,44 @@
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)
17
- import { createActor, fromPromise } from 'xstate';
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.
24
+ import { createPlayerBridge, createXStatePlaybookRuntime, adjudicatePlayerOutput, normalizeError, normalizeErrorCompact, normalizeErrorFull, parseJudgeJson, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
18
25
  import { codingMachine, } from './code.fsm.js';
19
26
  import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
20
- const BOSS_REPLY_ERRORS = {
21
- missingQuestion: "needsBossReply outcome missing 'question' field",
22
- unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
23
- };
27
+ function snapshotCodePlaybookOptions(value) {
28
+ const captured = snapshotJsonValue(value, 'CODE runtime options');
29
+ if (captured === null ||
30
+ typeof captured !== 'object' ||
31
+ Array.isArray(captured)) {
32
+ throw new TypeError('CODE runtime options must be an object');
33
+ }
34
+ const record = captured;
35
+ const allowed = new Set([
36
+ 'intent',
37
+ 'irNumber',
38
+ 'coderPlayer',
39
+ 'reviewerPlayer',
40
+ 'committerPlayer',
41
+ ]);
42
+ for (const [key, option] of Object.entries(record)) {
43
+ if (!allowed.has(key)) {
44
+ throw new TypeError(`CODE runtime options.${key} is not declared`);
45
+ }
46
+ if (typeof option !== 'string') {
47
+ throw new TypeError(`CODE runtime options.${key} must be a string`);
48
+ }
49
+ }
50
+ return captured;
51
+ }
24
52
  // Required-payload fields whose value is the player's verbatim long-form
25
53
  // prose. The runtime carries `finalText.trim()` into these fields rather
26
54
  // than asking the judge to round-trip the text through JSON. Short
@@ -30,59 +58,47 @@ const VERBATIM_PAYLOAD_FIELDS = new Set([
30
58
  'reviews',
31
59
  'challenges',
32
60
  ]);
33
- // Normalize an unknown error value to the compact `{ name, message }`
34
- // shape used by Captain-pane / status emissions. Returns `undefined`
35
- // for nullish input so callers can omit absent errors.
36
- function normalizeErrorCompact(err) {
37
- if (err === undefined || err === null)
61
+ // Normalize any `error` field inside a telemetry event so failed
62
+ // transitions don't leak raw Error instances through the channel.
63
+ function normalizeEventForTelemetry(event) {
64
+ if (event === undefined)
38
65
  return undefined;
39
- if (err instanceof Error) {
40
- return { name: err.name, message: err.message };
41
- }
42
- if (typeof err === 'object') {
43
- const o = err;
44
- if (typeof o.message === 'string') {
45
- return {
46
- name: typeof o.name === 'string' ? o.name : 'Error',
47
- message: o.message,
48
- };
49
- }
50
- }
51
- return { name: 'Error', message: String(err) };
66
+ return normalizeEventValue(event, 'FSM event', new Set());
52
67
  }
53
- // Normalize an unknown error value to the full `{ name, message, stack }`
54
- // shape used by telemetry emissions. Returns `undefined` for nullish
55
- // input. `stack` is omitted when not available on the source value.
56
- function normalizeErrorFull(err) {
57
- const compact = normalizeErrorCompact(err);
58
- if (compact === undefined)
59
- return undefined;
60
- if (err instanceof Error) {
61
- return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
62
- }
63
- if (typeof err === 'object' && err !== null) {
64
- const stack = err.stack;
65
- if (typeof stack === 'string') {
66
- return { ...compact, stack };
68
+ function normalizeEventValue(value, path, ancestors) {
69
+ if (Array.isArray(value))
70
+ return snapshotJsonValue(value, path);
71
+ if (value === null || typeof value !== 'object') {
72
+ return snapshotJsonValue(value, path);
73
+ }
74
+ if (ancestors.has(value)) {
75
+ throw new TypeError(`${path} must not contain a JSON cycle`);
76
+ }
77
+ const prototype = Object.getPrototypeOf(value);
78
+ if (prototype !== Object.prototype && prototype !== null) {
79
+ return snapshotJsonValue(value, path);
80
+ }
81
+ if (Object.getOwnPropertySymbols(value).length > 0) {
82
+ return snapshotJsonValue(value, path);
83
+ }
84
+ const nextAncestors = new Set(ancestors).add(value);
85
+ const normalized = {};
86
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
87
+ if (!descriptor.enumerable) {
88
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
89
+ }
90
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
91
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
67
92
  }
93
+ if (descriptor.value === undefined)
94
+ continue;
95
+ normalized[key] =
96
+ key === 'error'
97
+ ? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
98
+ : normalizeEventValue(descriptor.value, `${path}.${key}`, nextAncestors);
68
99
  }
69
- return compact;
100
+ return snapshotJsonValue(normalized, path);
70
101
  }
71
- // Normalize any `error` field inside a telemetry event so failed
72
- // transitions don't leak raw Error instances through the channel.
73
- function normalizeEventForTelemetry(event) {
74
- if (event === null || typeof event !== 'object' || Array.isArray(event)) {
75
- return event;
76
- }
77
- const e = event;
78
- if (!('error' in e))
79
- return event;
80
- const normalized = normalizeErrorFull(e.error);
81
- return { ...e, error: normalized };
82
- }
83
- // Internal capabilities (DR-004 §10). Each ships with its final
84
- // signature; behavior lands in the per-capability task noted by the
85
- // TODO marker.
86
102
  // Player-prompt composer — DR-004 §6.
87
103
  // Substitutes the three placeholder tokens in `input.prompt` (literal
88
104
  // string replace, no escaping) and arranges labelled blocks around
@@ -163,52 +179,6 @@ function resolvePlayerId(input) {
163
179
  }
164
180
  }
165
181
  }
166
- // LLM judge — DR-004 §4. Builds a prompt that lists each declared
167
- // outcome verbatim, asks ports.callJudge for a JSON
168
- // `{ guard, …payloadFields }` response, and returns the parsed
169
- // object once the chosen guard is one of the input.result keys.
170
- // Adjudicator failures (malformed JSON, missing/unknown guard) are
171
- // control-plane errors and propagate via throw per slc/link.md.
172
- async function adjudicate(input, finalText, ports, signal) {
173
- const prompt = buildJudgePrompt(input, finalText);
174
- const raw = await ports.callJudge(prompt, signal);
175
- const parsed = parseJudgeJson(raw);
176
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
177
- throw new Error('adjudicate: judge response is not a JSON object');
178
- }
179
- const obj = parsed;
180
- const guard = obj.guard;
181
- if (typeof guard !== 'string') {
182
- throw new Error('adjudicate: judge response missing string "guard" field');
183
- }
184
- if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
185
- throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
186
- }
187
- // Per slc/link.md, a missing payload field the state's `result`
188
- // description requires is a control-plane error. The FSM names
189
- // required fields with the literal phrase
190
- // Output shall include `<fieldName>: <...>`
191
- // so we extract those tokens and require each to be a string in
192
- // the judge response — except for VERBATIM_PAYLOAD_FIELDS
193
- // (`reviews`, `challenges`), where the runtime substitutes
194
- // `finalText.trim()` so the long-form prose is not round-tripped
195
- // through judge JSON. Short extracted fields like `question` and
196
- // `taskDescription` keep the existing extract-and-validate path.
197
- const verbatim = finalText.trim();
198
- for (const field of extractRequiredFields(input.result[guard])) {
199
- if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
200
- obj[field] = verbatim;
201
- continue;
202
- }
203
- if (typeof obj[field] !== 'string') {
204
- if (guard === 'needsBossReply' && field === 'question') {
205
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
206
- }
207
- throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
208
- }
209
- }
210
- return obj;
211
- }
212
182
  function extractRequiredFields(description) {
213
183
  const fields = [];
214
184
  const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
@@ -237,145 +207,23 @@ function buildJudgePrompt(input, finalText) {
237
207
  }
238
208
  return lines.join('\n');
239
209
  }
240
- // Judge replies are meant to be a single JSON object, but LLMs
241
- // routinely wrap them in prose ("Here is the result: …"), Markdown
242
- // code fences, or trailing commentary, and occasionally emit a
243
- // trailing comma or truncate the tail (a dropped closing brace, an
244
- // unterminated string). parseJudgeJson is deliberately lenient: it
245
- // first tries a strict parse of the (optionally fenced) body, then
246
- // scans every `{`/`[` as a possible start in document order and
247
- // returns the first recoverable object. At each start it prefers a
248
- // strict balanced span and falls back to a repaired (trailing-comma /
249
- // truncation) span, so a damaged object earlier in the prose is not
250
- // overridden by a cleaner one later. Scanning every start (not just
251
- // the first bracket) keeps a bracketed fragment in surrounding prose
252
- // e.g. an aside like `see [1]` or `{n/a}` before the real object —
253
- // from masking a later, genuinely valid object. Both callers
254
- // (classification and adjudication) expect an object, so plain objects
255
- // win over arrays/scalars; the first value of any shape is remembered
256
- // so a legitimately array/scalar reply still surfaces to the caller's
257
- // own object check. Only a reply from which no JSON value can be
258
- // recovered is treated as malformed and throws, preserving the
259
- // control-plane error contract (PBRT-7, PBRT-10).
260
- function parseJudgeJson(raw) {
261
- const fenced = stripCodeFence(raw.trim());
262
- // Fast path: a well-formed (optionally fenced) JSON body.
263
- try {
264
- return JSON.parse(fenced);
265
- }
266
- catch {
267
- // Fall through to lenient extraction + repair.
268
- }
269
- const starts = [];
270
- for (let i = 0; i < fenced.length; i++) {
271
- const ch = fenced[i];
272
- if (ch === '{' || ch === '[')
273
- starts.push(i);
274
- }
275
- // Walk starts in document order. At each start prefer a strict
276
- // balanced span (most trustworthy) and fall back to a repaired one
277
- // for a trailing-comma / truncated tail, so the earliest intended
278
- // object wins even when it needs repair. Return the first plain
279
- // object; remember the first value of any shape so a legitimately
280
- // array/scalar reply still surfaces to the caller's own object check.
281
- let firstValue;
282
- for (const start of starts) {
283
- let parsedHere;
284
- for (const repair of [false, true]) {
285
- const candidate = extractJsonValue(fenced, start, repair);
286
- if (candidate === undefined)
287
- continue;
288
- try {
289
- parsedHere = { value: JSON.parse(candidate) };
290
- }
291
- catch {
292
- continue; // not parseable this way — try repair, then next start
293
- }
294
- break; // prefer the strict span at this start over its repair
295
- }
296
- if (parsedHere === undefined)
297
- continue;
298
- if (isPlainObject(parsedHere.value))
299
- return parsedHere.value;
300
- if (firstValue === undefined)
301
- firstValue = parsedHere;
302
- }
303
- if (firstValue !== undefined)
304
- return firstValue.value;
305
- throw new Error('adjudicate: judge response is not valid JSON');
306
- }
307
- function isPlainObject(value) {
308
- return typeof value === 'object' && value !== null && !Array.isArray(value);
309
- }
310
- // Strip a single Markdown code fence that wraps the whole string.
311
- function stripCodeFence(text) {
312
- const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
313
- return fence ? fence[1].trim() : text;
314
- }
315
- // Scan from `start` (a `{`/`[` index), tracking string and
316
- // bracket-nesting state, and emit the balanced JSON value rooted
317
- // there. Anything after the top-level value closes is ignored (so a
318
- // trailing code fence or commentary does not matter). With
319
- // `repair === false` the span is returned only if it actually closes,
320
- // and trailing commas are left intact — so the caller can prefer a
321
- // cleanly-balanced span before attempting repair; if input ends
322
- // before the value closes, undefined is returned. With
323
- // `repair === true` common damage is fixed: a trailing comma before a
324
- // close is removed, an unterminated string is closed, and any
325
- // brackets still open at end-of-input are closed in order.
326
- function extractJsonValue(text, start, repair) {
327
- const stack = [];
328
- let out = '';
329
- let inString = false;
330
- let escaped = false;
331
- for (let i = start; i < text.length; i++) {
332
- const ch = text[i];
333
- if (inString) {
334
- out += ch;
335
- if (escaped)
336
- escaped = false;
337
- else if (ch === '\\')
338
- escaped = true;
339
- else if (ch === '"')
340
- inString = false;
341
- continue;
342
- }
343
- if (ch === '"') {
344
- inString = true;
345
- out += ch;
346
- continue;
347
- }
348
- if (ch === '{' || ch === '[') {
349
- stack.push(ch === '{' ? '}' : ']');
350
- out += ch;
351
- continue;
352
- }
353
- if (ch === '}' || ch === ']') {
354
- if (repair)
355
- out = dropTrailingComma(out);
356
- out += ch;
357
- stack.pop();
358
- if (stack.length === 0)
359
- return out; // top-level value complete
360
- continue;
361
- }
362
- out += ch;
363
- }
364
- // End of input before the top-level value closed.
365
- if (!repair)
366
- return undefined; // strict pass: no balanced span here
367
- if (inString)
368
- out += '"';
369
- out = dropTrailingComma(out);
370
- while (stack.length > 0)
371
- out += stack.pop();
372
- return out;
373
- }
374
- // Remove a trailing comma (and any whitespace after it) at the end of
375
- // the accumulated output, so `{"a":1,}` / `[1,2,]` and truncated
376
- // `{"a":1,` repair to valid JSON.
377
- function dropTrailingComma(out) {
378
- return out.replace(/,(\s*)$/, '$1');
210
+ // CODE-specific adjudication strategy: the CODE judge prompt above, the
211
+ // DR-004 `Output shall include` required-field extraction, and the
212
+ // verbatim long-form payload fields.
213
+ const CODE_ADJUDICATION = {
214
+ buildJudgePrompt: (input, finalText) => buildJudgePrompt(input, finalText),
215
+ extractRequiredFields,
216
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
217
+ };
218
+ // LLM judge DR-004 §4. Delegates to the shared adjudicator with the
219
+ // CODE strategy: it lists each declared outcome verbatim, asks
220
+ // ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
221
+ // returns the parsed object once the chosen guard is one of the
222
+ // input.result keys. Adjudicator failures (malformed JSON,
223
+ // missing/unknown guard) are control-plane errors and propagate via
224
+ // throw per slc/link.md.
225
+ async function adjudicate(input, finalText, ports, signal, boundary) {
226
+ return (await adjudicatePlayerOutput(CODE_ADJUDICATION, input, finalText, ports, signal, boundary));
379
227
  }
380
228
  // Boss-event classifier — DR-004 §3.
381
229
  // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
@@ -384,19 +232,22 @@ function dropTrailingComma(out) {
384
232
  // classifier is state-aware so awaitBossReply can distinguish a direct
385
233
  // answer (BOSS_REPLY) from a fresh directive that abandons the pending
386
234
  // question through the FSM's existing transitions.
387
- async function classifyBossText(text, ports, signal, snapshotOrState) {
235
+ async function classifyBossText(text, ports, signal, snapshotOrState, boundary) {
388
236
  const trimmed = text.trim();
389
237
  if (trimmed === '')
390
238
  return undefined;
391
- return classifyWithLlm(text, ports, signal, snapshotOrState);
239
+ return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
392
240
  }
393
241
  const rootEvents = enumerateRootEvents(codingMachine);
394
242
  const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
395
243
  const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
396
- async function classifyWithLlm(text, ports, signal, snapshotOrState) {
244
+ async function classifyWithLlm(text, ports, signal, snapshotOrState, boundary) {
397
245
  const state = classifierState(snapshotOrState);
398
246
  const prompt = buildClassifierPrompt(text, state);
399
- const raw = await ports.callJudge(prompt, signal);
247
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
248
+ const raw = boundary
249
+ ? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
250
+ : await ports.callJudge(prompt, signal);
400
251
  let parsed;
401
252
  try {
402
253
  parsed = parseJudgeJson(raw);
@@ -479,7 +330,26 @@ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
479
330
  await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
480
331
  return undefined;
481
332
  }
482
- return { type: 'BOSS_REPLY', answer: payload.answer };
333
+ const pending = pendingBossQuestionFromContext(state.context);
334
+ if (!pending) {
335
+ await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
336
+ return undefined;
337
+ }
338
+ if (payload.questionId !== undefined &&
339
+ typeof payload.questionId !== 'string') {
340
+ await ports.emitStatus('Classifier supplied a non-string questionId for BOSS_REPLY');
341
+ return undefined;
342
+ }
343
+ if (typeof payload.questionId === 'string' &&
344
+ payload.questionId !== pending.questionId) {
345
+ await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`);
346
+ return undefined;
347
+ }
348
+ return {
349
+ type: 'BOSS_REPLY',
350
+ answer: payload.answer,
351
+ questionId: pending.questionId,
352
+ };
483
353
  }
484
354
  default:
485
355
  await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
@@ -503,7 +373,9 @@ function classifierState(snapshotOrState) {
503
373
  return { value: snapshotOrState, context: {} };
504
374
  }
505
375
  function buildClassifierPrompt(text, state) {
506
- const currentState = typeof state.value === 'string' ? state.value : 'unknown';
376
+ const currentState = typeof state.value === 'string'
377
+ ? state.value
378
+ : JSON.stringify(state.value ?? null);
507
379
  const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
508
380
  const lines = [
509
381
  'Classify the following Boss message into exactly one of these events.',
@@ -513,14 +385,14 @@ function buildClassifierPrompt(text, state) {
513
385
  `Current state: ${currentState}`,
514
386
  ];
515
387
  if (pendingBossQuestion !== undefined) {
516
- lines.push(`Pending Boss question: ${pendingBossQuestion.question}`, `Pending resume state: ${pendingBossQuestion.resumeStateId}`);
388
+ lines.push(`Pending question id: ${pendingBossQuestion.questionId}`, `Pending asking player: ${pendingBossQuestion.player}`, `Pending Boss question: ${pendingBossQuestion.question}`);
517
389
  }
518
390
  lines.push('', 'Events:', '- START_CODING: payload { intent: "<free-form goal>" }', '- CONTINUE_IR: payload { irNumber: "<number>" }', '- SUMMARIZE_IR: payload { irNumber: "<number>" }', '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }', ' targetId must be one of these jumpable states:');
519
391
  for (const target of bossInterruptTargets) {
520
392
  lines.push(` - ${target.stateId}: ${target.description}`);
521
393
  }
522
394
  if (currentState === 'awaitBossReply') {
523
- lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
395
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }');
524
396
  }
525
397
  else {
526
398
  lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
@@ -528,36 +400,28 @@ function buildClassifierPrompt(text, state) {
528
400
  lines.push('', 'Boss message:', '```', text, '```');
529
401
  return lines.join('\n');
530
402
  }
531
- // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
532
- // codingMachine invokes from every captain-invoking state. Per turn:
533
- // resolve playerId, compose the player prompt, await
534
- // ports.callPlayer, adjudicate the finalText. PlayerResult status of
535
- // 'aborted' or 'error' throws so XState routes via onError → #failed
536
- // (the single fail-stop sink for both Captain errors and player
537
- // failures).
403
+ // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
404
+ // codingMachine invokes from every player-invoking state, built by the
405
+ // shared createPlayerBridge with the CODE binding, composer, and
406
+ // adjudication strategy. Per turn: resolve playerId, compose the player
407
+ // prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
408
+ // status of 'aborted' or 'error' throws so XState routes via onError →
409
+ // #failed (the single fail-stop sink for both Captain errors and player
410
+ // failures). Captain remains the orchestrator and adjudicator; it is not
411
+ // encoded as the delegated FSM actor.
538
412
  //
539
413
  // `getActiveSignal` is the runtime's hook for flowing the Boss's
540
414
  // `handleBossInput.signal` into the host port calls — fromPromise
541
415
  // hands the bridge XState's actor-scoped signal, which only fires
542
416
  // on actor.stop(), not on Boss abort. When omitted (e.g. direct
543
417
  // captainBridge tests), the bridge falls back to XState's signal.
544
- function captainBridge(ports, getActiveSignal) {
545
- return fromPromise(async ({ input, signal }) => {
546
- const activeSignal = getActiveSignal?.() ?? signal;
547
- const playerId = resolvePlayerId(input);
548
- const prompt = composePlayerPrompt(input);
549
- const result = await ports.callPlayer(playerId, prompt, activeSignal);
550
- if (result.status !== 'ok') {
551
- throw new Error(result.error ??
552
- `captainBridge: callPlayer status "${result.status}"`);
553
- }
554
- if (result.finalText === undefined) {
555
- throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
556
- }
557
- const output = await adjudicate(input, result.finalText, ports, activeSignal);
558
- validateBossReplyOutput(input, output);
559
- return output;
560
- });
418
+ function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
419
+ return createPlayerBridge({
420
+ resolvePlayerId: (input) => resolvePlayerId(input),
421
+ composePlayerPrompt: (input) => composePlayerPrompt(input),
422
+ adjudication: CODE_ADJUDICATION,
423
+ resumableStateIds: registeredResumableStateIds,
424
+ }, ports, getActiveSignal, boundary, onControlPlaneError);
561
425
  }
562
426
  // Captain pane display — PBRT-3 / PBRT-14.
563
427
  // The Captain pane is a stream of three glyphs plus one bare
@@ -565,7 +429,7 @@ function captainBridge(ports, getActiveSignal) {
565
429
  // glance:
566
430
  // (no glyph) bare FSM event type — host renders as captain speech
567
431
  // (e.g., `captain> START_CODING`)
568
- // ⤷ captain-invoking state entry: `<Player>: <label>`
432
+ // ⤷ player-invoking state entry: `<Player>: <label>`
569
433
  // → transition guard outcome (`· field=N` tallies
570
434
  // appended); the host presenter owns any visual
571
435
  // nesting under the preceding ⤷ entry
@@ -602,29 +466,14 @@ const stateMetadata = (() => {
602
466
  for (const s of enumerateCaptainStates(codingMachine)) {
603
467
  const label = STATE_LABELS[s.stateId];
604
468
  if (!label) {
605
- throw new Error(`code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`);
469
+ throw new Error(`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`);
606
470
  }
607
471
  const input = s.getInput({});
608
472
  m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
609
473
  }
610
474
  return m;
611
475
  })();
612
- const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
613
- meta.sourceItem,
614
- stateId,
615
- ]));
616
476
  const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
617
- function validateBossReplyOutput(input, output) {
618
- if (output.guard !== 'needsBossReply')
619
- return;
620
- if (typeof output.question !== 'string') {
621
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
622
- }
623
- const stateId = stateIdBySourceItem.get(input.sourceItem);
624
- if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
625
- throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem));
626
- }
627
- }
628
477
  const QUIESCENT_STATES = new Set([
629
478
  'ready',
630
479
  'awaitBossReply',
@@ -635,11 +484,8 @@ const QUIESCENT_STATES = new Set([
635
484
  // pane per PBRT-3: the readline returning to its `boss>` prompt is
636
485
  // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
637
486
  // tombstone is redundant.
638
- const SUPPRESSED_ENTRY_STATES = new Set([
639
- 'ready',
640
- 'done',
641
- ]);
642
- // Captain-pane surface (PBRT-3): every captain-invoking state plus
487
+ const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
488
+ // Captain-pane surface (PBRT-3): every player-invoking state plus
643
489
  // the quiescent states whose entry still carries information
644
490
  // (failure with `lastError`, awaitBossReply with the pending
645
491
  // question). `ready` and `done` flow through the inspect handler
@@ -656,12 +502,14 @@ function pendingBossQuestionFromContext(context) {
656
502
  }
657
503
  const candidate = pending;
658
504
  if (typeof candidate.resumeStateId !== 'string' ||
505
+ typeof candidate.questionId !== 'string' ||
659
506
  typeof candidate.sourceItem !== 'string' ||
660
507
  typeof candidate.player !== 'string' ||
661
508
  typeof candidate.question !== 'string') {
662
509
  return undefined;
663
510
  }
664
511
  return {
512
+ questionId: candidate.questionId,
665
513
  resumeStateId: candidate.resumeStateId,
666
514
  sourceItem: candidate.sourceItem,
667
515
  player: candidate.player,
@@ -726,9 +574,9 @@ function formatClassification(eventType) {
726
574
  }
727
575
  function stateTelemetryPayload(from, to, event, context) {
728
576
  const payload = {
729
- from,
577
+ from: from ?? null,
730
578
  to,
731
- event: normalizeEventForTelemetry(event),
579
+ event: normalizeEventForTelemetry(event) ?? null,
732
580
  };
733
581
  if (to === 'awaitBossReply') {
734
582
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
@@ -744,10 +592,40 @@ function stateTelemetryPayload(from, to, event, context) {
744
592
  }
745
593
  return payload;
746
594
  }
595
+ // Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
596
+ // the `→ guard` outcome line for the settling transition, then either
597
+ // the awaitBossReply question + rider-less marker pair or the state's
598
+ // entry line (with `lastError` data on `failed`).
599
+ function statusesForState(state, context, event) {
600
+ const to = state.stateId;
601
+ if (to === undefined || !CAPTAIN_PANE_STATES.has(to))
602
+ return [];
603
+ const statuses = [];
604
+ const transitionLine = formatTransition(event);
605
+ if (transitionLine !== undefined) {
606
+ statuses.push({ message: transitionLine });
607
+ }
608
+ if (to === 'awaitBossReply') {
609
+ statuses.push({ message: formatAwaitBossReplyQuestion(context) }, { message: formatAwaitBossReplyMarker(context) });
610
+ }
611
+ else {
612
+ const entryLine = formatStateEntry(to);
613
+ if (entryLine !== undefined) {
614
+ const lastError = to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
615
+ statuses.push({
616
+ message: entryLine,
617
+ ...(lastError === undefined
618
+ ? {}
619
+ : {
620
+ data: snapshotJsonValue({ lastError }, 'failed status data'),
621
+ }),
622
+ });
623
+ }
624
+ }
625
+ return statuses;
626
+ }
747
627
  // Internal export surface for tests. Not part of the stable public API;
748
- // the leading underscore signals "subject to change." Each member is
749
- // referenced here so `noUnusedLocals` stays clean while later tasks
750
- // wire the factory body to use them.
628
+ // the leading underscore signals "subject to change."
751
629
  export const _internal = {
752
630
  composePlayerPrompt,
753
631
  resolvePlayerId,
@@ -768,192 +646,25 @@ export const _internal = {
768
646
  normalizeEventForTelemetry,
769
647
  VERBATIM_PAYLOAD_FIELDS,
770
648
  };
771
- export default function createPlaybookRuntime(options) {
772
- let actor;
773
- let savedPorts;
774
- // The Boss's per-turn AbortSignal, surfaced to captainBridge so
775
- // ports.callPlayer / callJudge see the right cancellation source.
776
- // null between turns; set by handleBossInput.
777
- let activeSignal;
778
- // Previous root-machine state for the inspect-driven telemetry /
779
- // status emitter. undefined before the first inspect firing.
780
- let priorState;
781
- // Emission queue. slc/link.md says emissions "shall be ordered,
782
- // awaited, and never-dropped"; subscribe/inspect callbacks are
783
- // synchronous and can't await, so each emit is enqueued and a
784
- // single drainer processes them sequentially.
785
- const emitQueue = [];
786
- let drainer;
787
- function enqueueEmit(fn) {
788
- emitQueue.push(fn);
789
- if (!drainer) {
790
- drainer = (async () => {
791
- while (emitQueue.length > 0) {
792
- try {
793
- await emitQueue.shift()();
794
- }
795
- catch {
796
- // Suppress host-side emission errors; the control plane
797
- // surfaces real failures via handleBossInput throws.
798
- }
799
- }
800
- drainer = undefined;
801
- })();
802
- }
803
- }
804
- function drainEmissions() {
805
- return drainer ?? Promise.resolve();
806
- }
807
- function buildActor(ports) {
808
- priorState = undefined;
809
- return createActor(codingMachine.provide({
810
- actors: { captain: captainBridge(ports, () => activeSignal) },
811
- }), {
812
- input: options,
813
- inspect: (inspectionEvent) => {
814
- if (inspectionEvent.type !== '@xstate.snapshot')
815
- return;
816
- const snap = inspectionEvent.snapshot;
817
- // Filter out captain sub-actor (fromPromise) snapshots —
818
- // only the root codingMachine snapshot has a string value.
819
- if (typeof snap.value !== 'string')
820
- return;
821
- const to = snap.value;
822
- if (priorState === to)
823
- return;
824
- const from = priorState;
825
- priorState = to;
826
- // Telemetry on every transition (PBRT-14).
827
- const context = snap.context ?? {};
828
- enqueueEmit(() => ports.emitTelemetry({
829
- topic: 'playbook.fsm.state',
830
- payload: stateTelemetryPayload(from, to, inspectionEvent.event, context),
831
- }));
832
- // Captain pane (PBRT-3 / PBRT-14): show the transition
833
- // guard first (when this is an actor-done transition with
834
- // a known guard), then the new state entry, then any
835
- // context riders the entering state cares about. Terminal
836
- // entry to `failed` carries `lastError` as the data arg.
837
- if (!CAPTAIN_PANE_STATES.has(to))
838
- return;
839
- const transitionLine = formatTransition(inspectionEvent.event);
840
- if (transitionLine !== undefined) {
841
- enqueueEmit(() => ports.emitStatus(transitionLine));
842
- }
843
- // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
844
- // full player question as captain speech, then the rider-less
845
- // routing marker. The full-question telemetry rides
846
- // stateTelemetryPayload above.
847
- if (to === 'awaitBossReply') {
848
- const questionLine = formatAwaitBossReplyQuestion(context);
849
- const markerLine = formatAwaitBossReplyMarker(context);
850
- enqueueEmit(() => ports.emitStatus(questionLine));
851
- enqueueEmit(() => ports.emitStatus(markerLine));
852
- return;
853
- }
854
- const entryLine = formatStateEntry(to);
855
- if (entryLine === undefined)
856
- return;
857
- if (to === 'failed') {
858
- const lastError = snap.context
859
- ?.lastError;
860
- const data = { lastError: normalizeErrorCompact(lastError) };
861
- enqueueEmit(() => ports.emitStatus(entryLine, data));
862
- }
863
- else {
864
- enqueueEmit(() => ports.emitStatus(entryLine));
865
- }
866
- },
867
- });
868
- }
869
- const runtime = {
870
- async init(ports) {
871
- savedPorts = ports;
872
- actor = buildActor(ports);
873
- actor.start();
874
- await drainEmissions();
875
- },
876
- async handleBossInput({ text, signal, }) {
877
- if (!actor || !savedPorts) {
878
- throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
879
- }
880
- activeSignal = signal;
881
- try {
882
- // 1. Classify non-empty text into an FSM event through the judge.
883
- const event = await classifyBossText(text, savedPorts, signal, actor.getSnapshot());
884
- // Empty input, no-action classifier output, or invalid classifier
885
- // output — nothing to send.
886
- if (event === undefined) {
887
- await drainEmissions();
888
- return;
889
- }
890
- // 2. Captain-pane classification line (PBRT-14): the bare
891
- // FSM event type, emitted before the FSM advances so the
892
- // host can render it as captain speech (e.g.,
893
- // `captain> START_CODING`). Enqueued so it interleaves
894
- // cleanly with the inspect-driven transition emissions.
895
- const echoPorts = savedPorts;
896
- enqueueEmit(() => echoPorts.emitStatus(formatClassification(event.type)));
897
- // 3. final state ('done') cannot accept new events — dispose
898
- // and reconstruct per DR-004 §5.
899
- if (actor.getSnapshot().status === 'done') {
900
- actor.stop();
901
- actor = buildActor(savedPorts);
902
- actor.start();
903
- }
904
- // 4. Send the event.
905
- actor.send(event);
906
- // 5. Drive to quiescence. On signal-abort we take no FSM
907
- // action: the captain bridge's awaited callPlayer rejects
908
- // naturally, the bridge throws, XState routes through
909
- // onError → #failed, and this loop sees the quiescent
910
- // snapshot and returns (DR-004 §8 natural rejection).
911
- await driveToQuiescence(actor);
912
- // Drain transition emissions before returning so the Boss
913
- // sees the final status line for this turn.
914
- await drainEmissions();
915
- }
916
- finally {
917
- activeSignal = undefined;
918
- }
919
- },
920
- async dispose() {
921
- if (actor) {
922
- actor.stop();
923
- actor = undefined;
924
- }
925
- // Drain any in-flight emissions per slc/link.md §Session
926
- // lifecycle ("stop the actor and drain pending port emissions").
927
- await drainEmissions();
928
- savedPorts = undefined;
929
- },
930
- // @internal — test-only escape hatch for inspecting the
931
- // underlying actor's snapshot. Most state assertions are now
932
- // expressible via the recorded emitStatus / emitTelemetry
933
- // calls (DR-004 §9); the hatch stays for the few cases where
934
- // direct context inspection is clearer (e.g., the dispose
935
- // teardown test).
936
- _getActor() {
937
- return actor;
938
- },
939
- };
940
- return runtime;
941
- }
942
- function driveToQuiescence(actor) {
943
- return new Promise((resolve) => {
944
- if (isQuiescent(actor.getSnapshot())) {
945
- resolve();
946
- return;
947
- }
948
- const sub = actor.subscribe((snap) => {
949
- if (isQuiescent(snap)) {
950
- sub.unsubscribe();
951
- resolve();
952
- }
953
- });
954
- });
955
- }
956
- function isQuiescent(snap) {
957
- const v = snap.value;
958
- return typeof v === 'string' && QUIESCENT_STATES.has(v);
959
- }
649
+ // The CODE-specific spec handed to the shared runtime factory
650
+ // (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
651
+ // boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
652
+ // DR-014 parked-session snapshot capability lives in
653
+ // @sublang/playbook/xstate-runtime; this spec carries only what is
654
+ // CODE-specific.
655
+ const runtimeSpec = {
656
+ label: 'CODE',
657
+ snapshotOptions: snapshotCodePlaybookOptions,
658
+ resolvePlayerId: (input) => resolvePlayerId(input),
659
+ composePlayerPrompt: (input) => composePlayerPrompt(input),
660
+ buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
661
+ extractRequiredFields,
662
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
663
+ resumableStateIds: registeredResumableStateIds,
664
+ classifyBossText: (text, ports, signal, snapshotOrState, boundary) => classifyBossText(text, ports, signal, snapshotOrState, boundary),
665
+ classificationStatus: (event) => formatClassification(event.type),
666
+ statusesForState,
667
+ normalizeTransitionEvent: (event) => normalizeEventForTelemetry(event),
668
+ };
669
+ const createPlaybookRuntime = createXStatePlaybookRuntime(codingMachine, runtimeSpec);
670
+ export default createPlaybookRuntime;