@sublang/playbook 0.8.0 → 1.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 (55) hide show
  1. package/README.md +243 -193
  2. package/package.json +52 -17
  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 +580 -0
  12. package/reference/sdlc/code.playbook/bin/run.js +893 -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 +470 -182
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
  21. package/reference/sdlc/code.playbook/code.playbook.js +1098 -202
  22. package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
  24. package/reference/sdlc/code.playbook/code.registry.js +49 -34
  25. package/reference/sdlc/code.playbook/code.registry.ts +75 -41
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -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 +1097 -80
  42. package/slc/optimize.md +88 -0
  43. package/slc/text2gears.md +247 -5
  44. package/src/runtime.d.ts +145 -3
  45. package/src/runtime.ts +200 -2
  46. package/src/xstate-runtime.d.ts +94 -0
  47. package/src/xstate-runtime.js +1247 -0
  48. package/src/xstate-runtime.ts +1802 -0
  49. package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
  50. package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
  51. package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
  52. package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
  53. package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
  54. package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
  55. package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
package/slc/link.md CHANGED
@@ -8,28 +8,30 @@ Compiles the [gears2fsm](gears2fsm.md) artifact into a **`PlaybookRuntime`**: a
8
8
 
9
9
  - Drives the FSM.
10
10
  - Classifies Boss input into typed events.
11
- - Runs the Captain-actor against the playbook's players.
12
- - Adjudicates player output into FSM guards.
11
+ - Runs direct-Captain, delegated-player, and nested-playbook actors.
12
+ - Executes deterministic script actors locally, without any agent.
13
+ - Adjudicates Captain and player output into FSM guards.
13
14
  - Surfaces transitions as status/telemetry.
14
15
 
15
16
  The runtime is invoked through the stable `PlaybookPorts` contract.
16
- Presentation layers (tmux-play, web, CLI, tests) implement the four ports once and inherit every playbook.
17
+ Presentation layers (tmux-play, web, CLI, tests) implement the six ports once
18
+ and inherit every playbook.
17
19
 
18
20
  - Source: an XState v5 machine artifact (`.fsm.ts`) produced by gears2fsm.
19
21
  - Target: a `PlaybookRuntime` factory module — TypeScript, host-agnostic.
20
22
 
21
23
  Hosts are out of scope for this phase.
22
- Each host has a small adapter (~30 lines) that loads a `PlaybookRuntime` module and supplies the host's primitives as `PlaybookPorts`.
24
+ Each host has an adapter that loads a `PlaybookRuntime` module and supplies the host's primitives as `PlaybookPorts`.
23
25
  The adapter shall speak only `PlaybookPorts` to the runtime and shall not leak host types back into it.
24
26
 
25
27
  The link compiler shall not modify the FSM artifact and shall not re-derive Captain prompts, result keys, or guard semantics — those are fixed by the FSM.
26
28
 
27
29
  ## Formats
28
30
 
29
- | Role | Format | Extension |
30
- | --- | --- | --- |
31
- | source | fsm | .ts |
32
- | target | playbook | .ts |
31
+ | Role | Format | Extension |
32
+ | ------ | -------- | --------- |
33
+ | source | fsm | .ts |
34
+ | target | playbook | .ts |
33
35
 
34
36
  ## PlaybookRuntime contract
35
37
 
@@ -37,11 +39,80 @@ The emitted module shall default-export a factory of the following shape:
37
39
 
38
40
  ```typescript
39
41
  interface PlaybookRuntime {
40
- init(ports: PlaybookPorts): Promise<void>;
41
- handleBossInput(turn: { text: string; signal: AbortSignal }): Promise<void>;
42
+ init(session: PlaybookSession): Promise<void>;
43
+ handleBossInput(turn: {
44
+ text: string;
45
+ signal: AbortSignal;
46
+ }): Promise<PlaybookRunResult>;
47
+ resumePlaybookCall(input: {
48
+ callId: string;
49
+ result: PlaybookCallResult;
50
+ signal: AbortSignal;
51
+ }): Promise<PlaybookRunResult>;
42
52
  dispose(): Promise<void>;
43
53
  }
44
54
 
55
+ interface PlaybookSession {
56
+ sessionId: string;
57
+ playbookId: string;
58
+ rootSessionId: string;
59
+ parentSessionId?: string;
60
+ parentCallId?: string;
61
+ depth: number;
62
+ ports: PlaybookPorts;
63
+ }
64
+
65
+ type JsonValue =
66
+ | null
67
+ | boolean
68
+ | number
69
+ | string
70
+ | readonly JsonValue[]
71
+ | { readonly [key: string]: JsonValue };
72
+
73
+ interface NormalizedError {
74
+ name: string;
75
+ message: string;
76
+ stack?: string;
77
+ }
78
+
79
+ type PlaybookStateValue =
80
+ | string
81
+ | { readonly [key: string]: PlaybookStateValue };
82
+
83
+ interface PlaybookState {
84
+ value: PlaybookStateValue;
85
+ activeStateIds: readonly string[];
86
+ tags: readonly string[];
87
+ status: 'active' | 'done' | 'error' | 'stopped';
88
+ quiescent: boolean;
89
+ stateId?: string;
90
+ }
91
+
92
+ interface PlaybookPendingCall {
93
+ callId: string;
94
+ playbookId: string;
95
+ childSessionId: string;
96
+ }
97
+
98
+ type PlaybookRunResult =
99
+ | { outcome: 'quiescent' | 'no-action'; state: PlaybookState }
100
+ | {
101
+ outcome: 'failed' | 'aborted';
102
+ state: PlaybookState;
103
+ error?: NormalizedError;
104
+ }
105
+ | {
106
+ outcome: 'terminal';
107
+ state: PlaybookState;
108
+ output?: JsonValue;
109
+ }
110
+ | {
111
+ outcome: 'suspended';
112
+ state: PlaybookState;
113
+ pendingCall: PlaybookPendingCall;
114
+ };
115
+
45
116
  type PlaybookRuntimeFactory<Options = unknown> = (
46
117
  options: Options,
47
118
  ) => PlaybookRuntime;
@@ -53,46 +124,330 @@ export default function createPlaybookRuntime(
53
124
 
54
125
  The default export conforms to `PlaybookRuntimeFactory<PlaybookRuntimeOptions>`, the generic factory type the shared contract module exposes (§Output).
55
126
 
56
- `init` receives the host's ports, constructs the XState actor with FSM `input` derived from `options`, and starts the actor.
127
+ `init` receives the host-owned playbook session identity and ports, constructs the XState actor with FSM `input` derived from `options`, and starts the actor.
57
128
  The runtime owns the actor for its lifetime; `handleBossInput` runs one turn, and `dispose` stops the actor and drains pending port emissions.
58
-
59
- `PlaybookRuntimeOptions` is host-agnostic and carries only *per-run* knobs such as identity strings (e.g., model names a playbook substitutes into prompt placeholders) and strategy overrides the linker exposes.
129
+ The host shall generate a non-empty, globally unique `sessionId` for each init-to-dispose lifecycle and shall supply the stable registry or authored playbook id as `playbookId`.
130
+ The runtime shall validate non-empty session, playbook, and root ids, a safe
131
+ non-negative integer depth, root identity (`depth === 0` and
132
+ `rootSessionId === sessionId` with no parent fields), and child identity
133
+ (`depth > 0` with non-empty parent session and call ids). It shall copy those
134
+ identity scalars and the port references into its own immutable record rather
135
+ than retaining the caller's mutable session object. A child `sessionId` shall
136
+ differ from both its `rootSessionId` and `parentSessionId`.
137
+
138
+ Run outcomes are exact: `no-action` means no FSM event was sent;
139
+ `quiescent` means a non-failure parked/idle state; `failed` means the FSM is in
140
+ a recoverable failure state; `terminal` means top-level final with optional
141
+ JSON output; `aborted` means the turn signal ended work; and `suspended` means
142
+ exactly one `pendingCall` is active.
143
+ Control-plane exceptions reject the runtime method rather than masquerade as a
144
+ recoverable workflow `failed` result.
145
+
146
+ `PlaybookRuntimeOptions` is host-agnostic and carries only _per-run_ knobs such as identity strings (e.g., model names a playbook substitutes into prompt placeholders) and strategy overrides the linker exposes.
60
147
  The link compiler emits a typed options interface per playbook based on the FSM's `CodingInput` (or equivalent).
61
-
62
- Player binding is a *linker-time* input baked into the emitted runtime by default.
148
+ The CLI's absence of `--link-option` values does not mean that
149
+ `PlaybookRuntimeOptions` is empty. CLI link options are compile-time inputs;
150
+ the runtime options interface is independently derived from every required FSM
151
+ input field that is not supplied by `PlaybookSession` or another linker-owned
152
+ source. In particular, a required immutable `enabledPlaybooks` catalog shall
153
+ remain a required readonly runtime option passed through to machine input; the
154
+ linker shall neither invent an empty catalog nor require it to be baked into a
155
+ CLI link option.
156
+
157
+ Player binding is a _linker-time_ input baked into the emitted runtime by default.
63
158
  A linker may also expose it via `PlaybookRuntimeOptions` for per-run remapping; the contract requires only that the runtime ship with a deterministic binding it applies at every `callPlayer` site.
64
159
 
65
160
  ## PlaybookPorts contract
66
161
 
67
162
  ```typescript
68
163
  interface PlaybookPorts {
69
- callPlayer(playerId: string, prompt: string, signal: AbortSignal):
70
- Promise<PlayerResult>;
71
- callJudge(prompt: string, signal: AbortSignal):
72
- Promise<string>;
164
+ callPlayer(
165
+ playerId: string,
166
+ prompt: string,
167
+ signal: AbortSignal,
168
+ options: PlayerCallOptions,
169
+ ): Promise<PlayerResult>;
170
+ callCaptain(
171
+ prompt: string,
172
+ signal: AbortSignal,
173
+ options: CaptainCallOptions,
174
+ ): Promise<CaptainResult>;
175
+ callJudge(prompt: string, signal: AbortSignal): Promise<string>;
176
+ callPlaybook(
177
+ request: PlaybookCallRequest,
178
+ signal: AbortSignal,
179
+ ): Promise<PlaybookCallStart>;
73
180
  emitStatus(message: string, data?: unknown): Promise<void>;
74
181
  emitTelemetry(event: { topic: string; payload: unknown }): Promise<void>;
75
182
  }
76
183
 
184
+ interface PlayerCallOptions {
185
+ resume: string | false;
186
+ }
187
+
188
+ interface CaptainCallOptions {
189
+ visibility: 'visible' | 'hidden';
190
+ resume: string | false;
191
+ allowedTools?: readonly string[];
192
+ }
193
+
77
194
  interface PlayerResult {
195
+ status: 'ok' | 'aborted' | 'error';
196
+ resumeToken?: string;
197
+ finalText?: string;
198
+ error?: string;
199
+ }
200
+
201
+ interface CaptainResult {
78
202
  status: 'ok' | 'aborted' | 'error';
79
203
  finalText?: string;
80
204
  error?: string;
81
205
  }
206
+
207
+ interface PlaybookCallRequest {
208
+ callId: string;
209
+ playbookId: string;
210
+ text: string;
211
+ }
212
+
213
+ type PlaybookCallResult =
214
+ | {
215
+ status: 'ok';
216
+ playbookId: string;
217
+ childSessionId: string;
218
+ state?: PlaybookState;
219
+ output?: JsonValue;
220
+ }
221
+ | {
222
+ status: 'aborted';
223
+ playbookId: string;
224
+ childSessionId?: string;
225
+ state?: PlaybookState;
226
+ error?: NormalizedError;
227
+ }
228
+ | {
229
+ status: 'error';
230
+ playbookId: string;
231
+ childSessionId?: string;
232
+ state?: PlaybookState;
233
+ error: NormalizedError;
234
+ };
235
+
236
+ type PlaybookCallStart =
237
+ | { state: 'settled'; result: PlaybookCallResult }
238
+ | { state: 'suspended'; childSessionId: string };
82
239
  ```
83
240
 
84
- `PlayerResult` mirrors cligent's `PlayerRunResult` shape ([TMUX-033](https://github.com/sublang-ai/cligent/blob/main/specs/user/tmux-play.md#tmux-033)), so a tmux-play port adapter is direct assignment.
241
+ `PlayerResult` mirrors the status, resume token, final text, and error fields of cligent's `PlayerRunResult` ([TMUX-033](https://github.com/sublang-ai/cligent/blob/main/specs/user/tmux-play.md#tmux-033)).
85
242
  The runtime treats `status !== 'ok'` as a player failure and routes it through the FSM's error path (§Abort).
86
243
 
244
+ `callCaptain` runs a direct-Captain FSM actor against the host's Captain
245
+ agent. The linked runtime shall pass
246
+ `{ visibility: 'visible', resume: false }` for authored workflow calls so
247
+ XState context, rather than an agent conversation, owns workflow continuity.
248
+ The tool restriction is source-owned: the runtime shall additionally pass
249
+ `allowedTools: []` exactly when the GEARS source itself restricts the acting
250
+ Captain from tools (a routing-only Captain policy such as the default generic
251
+ Captain). A transformation-performing Captain — e.g. a compiler phase compiled
252
+ from a transformation-spec source, whose behavior writes a declared target
253
+ artifact — works through the host Captain's own tools, so its calls shall
254
+ carry no `allowedTools` restriction.
255
+ Accordingly, `CaptainCallOptions.allowedTools` is optional: an explicit empty
256
+ array requests a tool-free call, while omission preserves the host Captain's
257
+ configured tools.
258
+ `CaptainResult` carries no resume token or player-continuation selection.
259
+ A non-`ok`
260
+ result, or an `ok` result without `finalText`, shall reject the actor through
261
+ the FSM's error path.
262
+ Outside a signal-driven abort, those invalid direct-Captain results are
263
+ latched control-plane failures. The runtime shall let the actor take `onError`,
264
+ drive it to quiescence, and drain ordered emissions before the public method
265
+ rejects with the original failure. It shall never translate either case into
266
+ a recoverable workflow `{ outcome: 'failed' }` result. If the combined signal
267
+ has aborted, an aborted host result follows the ordinary abort settlement
268
+ instead of being promoted to a control-plane failure.
269
+
270
+ Every linked runtime owns a map from resolved player id to its latest non-empty `resumeToken`.
271
+ Before reading a resolved direct-Captain or delegated-player result, the
272
+ runtime shall validate, detach, and freeze it through the shared
273
+ `validateCaptainResult` or `validatePlayerResult` helper. The accepted object
274
+ shape is exact: only the declared status and optional string fields are
275
+ allowed, JSON-unsafe members reject, and caller mutation after resolution
276
+ cannot change trace evidence or player continuity. Validation happens before
277
+ adopting a resume token or reading final text.
278
+ The first call to each player in a playbook session shall pass `{ resume: false }`; later calls shall pass the exact stored token.
279
+ After a resolved call, the runtime shall replace the token when the result carries one or clear it when absent before interpreting `status`; a rejected call with no result leaves the prior token unchanged.
280
+ After awaiting a host Captain or player promise, the runtime shall re-check the
281
+ combined invocation/public-boundary signal before validating the result,
282
+ adopting a resume token, or emitting a successful finish. A host promise that
283
+ ignores cancellation and resolves late shall be paired as aborted and shall
284
+ not mutate continuity or masquerade as success.
285
+ The map survives actor reconstruction inside the same runtime and is discarded at `dispose`.
286
+ The runtime shall keep an in-flight set keyed by resolved player id and reject
287
+ a second concurrent call to the same id before crossing the host port. Calls
288
+ to distinct resolved player ids may overlap.
289
+
87
290
  `callJudge` returns free-form text.
88
291
  The runtime parses it per the state's adjudication strategy (§Captain adjudication).
89
292
  One port serves both classifier and adjudicator — they vary only in prompt.
293
+ Concurrent `callJudge` attempts within one linked runtime shall pass through
294
+ one abort-aware local FIFO. After the host promise resolves, the runtime shall
295
+ require a string reply and re-check the combined signal before tracing or
296
+ parsing success, so a non-cooperative late judge cannot outlive cancellation.
297
+ The host shall serialize `callCaptain` and `callJudge` together through one
298
+ shared abort-aware concurrency-one FIFO because both use the same single-flight
299
+ Captain lane, even when distinct player ports overlap [[4]]. A direct Captain
300
+ call's subsequent adjudication shall enter that same queue only after the
301
+ visible call has settled; the linked runtime shall not hold one queue lease
302
+ while requesting the other port.
303
+ Use one shared `PQueue({ concurrency: 1 })` for the individual host
304
+ `callCaptain` and `callJudge` promises. Do not pass an invocation or public
305
+ boundary signal as `PQueue.add(..., { signal })`: PQueue may release a running
306
+ slot as soon as that signal aborts even though a non-cooperative host promise
307
+ is still executing, which permits overlap. Instead check the combined signal
308
+ inside the queued task before crossing the host port, await the host promise
309
+ without releasing the queue lease, and check the signal again afterward.
310
+
311
+ `callPlaybook` starts a function-style child call.
312
+ The caller runtime supplies its stable call id and the XState invocation's
313
+ lifetime signal.
314
+ The host drives the child's initial text before resolving the port with either
315
+ an immediate settled result or a suspended child session.
316
+ Suspension is resumed later through `PlaybookRuntime.resumePlaybookCall`; the
317
+ port promise itself shall not remain pending across Boss turns.
90
318
 
91
319
  `emitStatus` is human-readable; `emitTelemetry` is structured.
92
320
  Both are async and shall be ordered, awaited, and never-dropped; the runtime awaits each emission before issuing the next.
93
321
 
94
322
  The runtime never speaks to LLMs directly and never touches host types beyond `PlaybookPorts`.
95
323
 
324
+ ## Playbook trace
325
+
326
+ Every linked runtime shall emit a boundary-complete, ordered trace through `emitTelemetry` topic `playbook.trace`.
327
+ Each payload shall carry `schemaVersion: 2`, the immutable session identity and
328
+ causality, a contiguous one-based `sequence`, a Unix-millisecond `timestamp`, a
329
+ trace `type`, event `payload`, and the runtime-local `turnId` / paired `callId`
330
+ where applicable.
331
+
332
+ ```typescript
333
+ type PlaybookTraceType =
334
+ | 'session.started'
335
+ | 'boss.input.received'
336
+ | 'judge.call.started'
337
+ | 'judge.call.finished'
338
+ | 'player.call.started'
339
+ | 'player.call.finished'
340
+ | 'captain.call.started'
341
+ | 'captain.call.finished'
342
+ | 'playbook.call.started'
343
+ | 'playbook.call.finished'
344
+ | 'fsm.transition'
345
+ | 'status.emitted'
346
+ | 'boss.input.settled'
347
+ | 'session.disposed';
348
+
349
+ interface PlaybookTraceEvent {
350
+ schemaVersion: 2;
351
+ sessionId: string;
352
+ playbookId: string;
353
+ rootSessionId: string;
354
+ parentSessionId?: string;
355
+ parentCallId?: string;
356
+ depth: number;
357
+ sequence: number;
358
+ timestamp: number;
359
+ type: PlaybookTraceType;
360
+ turnId?: number;
361
+ callId?: string;
362
+ payload: JsonValue;
363
+ }
364
+ ```
365
+
366
+ The trace types are `session.started`, `boss.input.received`,
367
+ `judge.call.started`, `judge.call.finished`, `player.call.started`,
368
+ `player.call.finished`, `captain.call.started`, `captain.call.finished`,
369
+ `playbook.call.started`,
370
+ `playbook.call.finished`, `fsm.transition`, `status.emitted`,
371
+ `boss.input.settled`, and `session.disposed`.
372
+ Call pairs carry exact prompts and replies, normalized failures, actor and state
373
+ identity, and their boundary-specific options.
374
+ Direct-Captain start and finish payloads shall carry `allowedTools` exactly when
375
+ the originating `CaptainCallOptions` selects it and shall omit the member when
376
+ the call preserves the host Captain's configured tools.
377
+ `session.started` and `session.disposed` carry their descriptor as top-level
378
+ `state` and its singular `stateId` when present. Every judge start and finish
379
+ carries the working snapshot's singular `stateId` when one exists;
380
+ classification uses the current descriptor and adjudication uses the invoking
381
+ actor input. The default Captain always has such a singular id, while a
382
+ parallel snapshot may omit it. Every judge finish also carries
383
+ `status: 'ok' | 'aborted' | 'error'`. Every `status.emitted` carries the
384
+ described top-level `state` and its singular `stateId` when present, as well as
385
+ its message and optional data; consumers shall not have to recover state
386
+ identity from a nested ad hoc object.
387
+ Judge results use `reply`; player start and finish payloads both carry the
388
+ selected `resume`; Captain start and finish payloads both carry
389
+ the exact composed prompt, `visibility: 'visible'`, the direct invocation's
390
+ `stateId` and `sourceItem`, and no player resume selection or resume token;
391
+ judge `purpose` is
392
+ `boss-input-classification`, `player-output-adjudication`, or
393
+ `captain-output-adjudication`; and every error uses
394
+ `{ name, message, stack? }` rather than a raw string or `Error` instance.
395
+ The Captain finish payload shall preserve the exact `CaptainResult` status and
396
+ final text when present, while carrying any failure in normalized form.
397
+ An `ok` result without `finalText` therefore retains status `ok` but also
398
+ carries the normalized missing-text failure that makes the actor reject.
399
+ If the Captain port rejects before returning a result, the finish instead
400
+ carries explicit `status: 'aborted'` when the combined signal has aborted or
401
+ `status: 'error'` otherwise. A finish boundary never omits status merely
402
+ because there was no structured host result.
403
+ The pair obligation also applies when a port promise rejects or throws: the
404
+ linked runtime shall emit and drain one normalized finish boundary before it
405
+ propagates the failure. No started call boundary may be left without its
406
+ matching finished boundary.
407
+ If a started-boundary sink records the event and then rejects, the runtime
408
+ shall make one best-effort normalized error-finish attempt with the same call
409
+ id and then reject the original start error. It shall not retry either event or
410
+ let a failure of that finish attempt replace the start error.
411
+ When a call boundary carries `callId`, that id shall be unique within the
412
+ runtime session. A stable FSM `stateId` is identity metadata in the payload,
413
+ not a call id and shall not be reused as one across repeated invocations.
414
+ Optional trace and run-result members shall be omitted when absent; the runtime
415
+ shall not create own `turnId`, `callId`, parent identity, output, or error
416
+ properties with value `undefined` and then rely on JSON serialization to drop
417
+ them.
418
+ A `boss.input.settled` payload shall project the complete structured run
419
+ result: its outcome must be one of the `PlaybookRunResult` discriminants (never
420
+ an invented `error` outcome), and it shall include `state`, singular
421
+ `stateId`, `pendingCall`, `output`, and normalized `error` whenever the matching
422
+ result arm carries them.
423
+ One runtime-owned concurrency-one emission queue shall serialize every trace,
424
+ human status, and state telemetry call. Sequence allocation and enqueueing
425
+ shall occur atomically, and every public method shall drain that queue before
426
+ resolving or rejecting. A state transition emission queued on entry shall be
427
+ observed before the invoked boundary's `*.started` event, even when a host
428
+ delays `emitTelemetry`.
429
+ The linked module shall use `PQueue({ concurrency: 1 })` from `p-queue` for
430
+ this ordering and drain it with `onIdle()` rather than recreate a promise-queue
431
+ implementation in every generated artifact.
432
+ An XState inspection callback shall synchronously enqueue the transition
433
+ trace, state telemetry, status trace, and human status in that order before it
434
+ returns. `emitStatus` likewise enqueues its trace and port emission in the same
435
+ synchronous call. Do not enqueue state telemetry or the status port from a
436
+ `trace(...).then(...)` continuation: the queue can become momentarily idle,
437
+ letting an invoked actor's `await drain()` overtake those dependent enqueues.
438
+ All validation happens before these synchronous enqueues; later sink failures
439
+ are caught into the appropriate latch without changing their queue position.
440
+ FSM trace events carry the same transition, pending-question, and normalized-error fields as state telemetry.
441
+ Trace emissions are awaited and sequenced before the boundary operation or human status/state telemetry they describe.
442
+ Every event in one session carries the same root/parent/depth identity.
443
+ A parent call start precedes its child `session.started`; the child's
444
+ `session.disposed` precedes the parent call finish.
445
+ Parallel call finishes may occur in either order, so consumers shall use call
446
+ ids for pairing and sequence for the observed total order.
447
+
448
+ This trace covers everything observable through `PlaybookRuntime`; host-specific adapter streaming remains in the host record stream.
449
+ Trace payloads never become Boss-visible status or prompt text.
450
+
96
451
  ## Linker inputs
97
452
 
98
453
  The link compiler shall accept:
@@ -101,6 +456,9 @@ The link compiler shall accept:
101
456
  - A **player binding** mapping GEARS players (declared in the
102
457
  [text2gears](text2gears.md#players) source) to opaque player-identifier
103
458
  strings.
459
+ Where no binding is supplied, the linker shall apply the default
460
+ binding — each player to its lowercased name (e.g. `Coder` → `coder`)
461
+ — and record the applied binding in the emitted header.
104
462
  - An **adjudication strategy** (default: LLM-judge per state) and a
105
463
  **Boss-event mapping** (default: free-text judge classification).
106
464
  Both strategies are host-agnostic.
@@ -109,12 +467,21 @@ The host's identity does not enter compilation; the linked module runs unchanged
109
467
 
110
468
  ## Player binding
111
469
 
112
- Each GEARS state names exactly one player (`invoke.input.player`).
113
- The linker shall map every named player to a `playerId` string used in `PlaybookPorts.callPlayer(playerId, …)`.
470
+ Each delegated GEARS state names exactly one player
471
+ (`player` actor `invoke.input.player`).
472
+ The linker shall map every named player to a `playerId` string used in
473
+ `PlaybookPorts.callPlayer(playerId, …)`.
114
474
  The host adapter routes that opaque string to its concrete primitive.
475
+ Every direct-Captain and delegated-player invocation shall also carry its
476
+ working leaf's explicit
477
+ `stateId`; a linked runtime shall use that field for call identity and shall
478
+ not infer one leaf from a structured root snapshot.
479
+ Direct `captain` actor states bypass player binding and call
480
+ `PlaybookPorts.callCaptain`; the linker shall not synthesize a player id named
481
+ `captain` for them.
115
482
 
116
483
  For composite players declared with aliases (e.g., `Committer = Coder | Reviewer`), the linker shall resolve the alias **per source item**.
117
- Resolution inspects the `CaptainInput` fields populated at that state:
484
+ Resolution inspects the `PlayerInput` fields populated at that state:
118
485
 
119
486
  - If only one `<playerName>Player` field is present, bind to that player.
120
487
  - If multiple are present, prefer the first-listed alternative in the alias declaration order.
@@ -122,20 +489,28 @@ Resolution inspects the `CaptainInput` fields populated at that state:
122
489
 
123
490
  Resolution shall be deterministic and recorded in the emitted module so future maintainers can audit it without re-running the linker.
124
491
 
125
- The linker shall not invent player identifiers and shall not silently collapse aliases at the FSM level — composite players keep their `player: 'Committer'` value on `CaptainInput`; resolution decides only the `callPlayer` invocation.
492
+ The linker shall not invent player identifiers beyond the recorded default
493
+ binding, and shall not silently collapse aliases at the FSM level — composite
494
+ players keep their `player: 'Committer'` value on `PlayerInput`; resolution
495
+ decides only the `callPlayer` invocation.
126
496
 
127
497
  ## Player prompt composition
128
498
 
129
- The runtime shall compose the actual player prompt from the state's `CaptainInput`.
499
+ The runtime shall compose the actual player prompt from the state's
500
+ `PlayerInput`.
130
501
  `input.prompt` is the GEARS-derived domain prompt body and shall not be mutated, re-flowed, or treated as a place to store framework control instructions.
131
502
 
132
- The composer may prepend structured labelled blocks from typed `CaptainInput` fields the FSM exposes (for example `Boss intent:`, `Review items:`, `Rebuttals:`, or `Task description:`).
503
+ The composer may prepend structured labelled blocks from typed `PlayerInput`
504
+ fields the FSM exposes (for example `Boss intent:`, `Review items:`,
505
+ `Rebuttals:`, or `Task description:`).
133
506
  Those blocks are outside the domain prompt body.
134
507
 
135
508
  The composer shall not inject a player-visible Boss-question instruction.
136
509
  Boss-question detection is adjudicator-facing: it comes from the state's `needsBossReply` result description, not from extra prompt text.
137
510
 
138
- When `CaptainInput` carries both `pendingBossQuestion` and `bossReply`, the composer shall prepend the continuation preamble and labelled Q&A blocks before ordinary structured blocks and before the domain prompt body:
511
+ When `PlayerInput` carries both `pendingBossQuestion` and `bossReply`, the
512
+ composer shall prepend the continuation preamble and labelled Q&A blocks before
513
+ ordinary structured blocks and before the domain prompt body:
139
514
 
140
515
  ```text
141
516
  You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.
@@ -145,52 +520,222 @@ Boss question:
145
520
 
146
521
  Boss reply:
147
522
  <bossReply>
523
+
148
524
  ```
149
525
 
150
526
  The continuation preamble is framework text supplied by the runtime.
151
527
  It is not part of the GEARS blockquote and shall not appear in `invoke.input.prompt`.
528
+ The composer shall retain the blank line after the Boss reply before the next
529
+ structured block or domain prompt, producing exactly two newline characters at
530
+ that boundary.
531
+ When implementing the prefix as an array joined with `"\n"`, the array needs
532
+ two trailing empty strings after `bossReply`; one trailing empty string emits
533
+ only one newline and is nonconformant. Equivalently, append `"\n\n"` exactly
534
+ once before the following block or domain body.
535
+
536
+ ## Captain prompt composition
537
+
538
+ The runtime shall compose a direct Captain prompt from the state's
539
+ `CaptainInput` under the same prompt-integrity rules: `input.prompt` remains the
540
+ verbatim GEARS domain body, while specific typed fields may be supplied as
541
+ labelled blocks and substituted for their declared placeholders.
542
+ It shall not introduce a player binding or player resume instruction.
543
+ String fields substitute verbatim. Arrays and objects such as the sanitized
544
+ enabled-playbook catalog, remaining plan, and completed child results shall be
545
+ validated as JSON-safe and rendered as deterministic JSON; they shall never be
546
+ coerced through default JavaScript string conversion or expose untyped context.
547
+ Deterministic rendering shall sort object keys lexicographically at every
548
+ depth while preserving array order, so equivalent JSON values produce the same
549
+ prompt independent of host property insertion order.
550
+ At construction, a structured host-owned catalog shall be validated against its
551
+ declared exact entry shape, copied, and frozen recursively so later caller
552
+ mutation or extra properties cannot alter a prompt or machine decision.
553
+ For the default Captain catalog, every entry has exactly the own enumerable
554
+ data keys `id`, `command`, and `intent`; all three values are non-empty strings,
555
+ and `id` values are unique. Empty values, duplicate ids, extra keys, accessors,
556
+ non-plain objects, and non-JSON data reject runtime construction rather than
557
+ being silently repaired or discarded.
558
+
559
+ When a direct Captain task resumes from its own Boss question, the composer
560
+ shall prepend the same continuation preamble and labelled Q&A blocks defined in
561
+ §Player prompt composition. The runtime shall pass the complete composed prompt
562
+ once to `callCaptain` with `{ visibility: 'visible', resume: false }` and the
563
+ same source-owned tool restriction as the originating call; it shall not
564
+ expose the
565
+ subsequent adjudicator prompt or structured judge reply through that visible
566
+ call.
567
+ The composed prompt shall contain only the GEARS blockquote, typed runtime
568
+ evidence blocks, and the continuation preamble.
569
+ It shall not append the state's result map, guard names, result-property
570
+ schema, adjudication request, workspace context, or tool instructions.
571
+ The shared Captain composer shall replace every known placeholder whose
572
+ matching typed field is present in the supplied input. It shall not choose one
573
+ exclusive replacement set from `stateId`, source-item identity, or another
574
+ variant discriminator. Verification may deliberately combine catalog,
575
+ intent, plan, result, question, and reply fields in one synthetic input; every
576
+ matching placeholder in that template still has to be rendered.
577
+ Construct the replacement table from field presence alone. In particular,
578
+ populate `<remaining-plan>` when `remainingPlan` is supplied and
579
+ `<completed-call-results>` when `completedCallResults` is supplied, regardless
580
+ of the input's `stateId` or `sourceItem`. An implementation branch such as
581
+ `if (input.stateId === 'reassessment')` around either replacement is
582
+ nonconformant.
152
583
 
153
584
  ## Boss-event mapping
154
585
 
155
586
  The FSM's `events` union enumerates every Boss-originated event.
156
- The runtime receives Boss input as a free-form string (`handleBossInput.text`) and shall classify each non-empty turn into one of the FSM's events plus its payload, or no FSM action, by invoking `callJudge`.
157
- Empty or whitespace-only text produces no event and no port call.
158
-
159
- The classifier prompt shall demand JSON against the FSM's typed event union and any state-specific Boss input contract, including the payload fields required for each event.
160
- When the FSM supports a Boss-reply suspension state, the prompt shall include the current state and the pending Boss question so the judge can distinguish a reply from a fresh directive.
587
+ The runtime receives Boss input as a free-form string
588
+ (`handleBossInput.text`).
589
+ Where the current ready or reconstructed terminal machine accepts exactly one
590
+ ordinary textual entry event and no Boss question is pending, the runtime
591
+ shall send that event deterministically and attach the exact original text to
592
+ its declared textual payload field without invoking `callJudge`.
593
+ The default Captain's ready entry is
594
+ `{ type: 'BOSS_INTENT', bossIntent: turn.text }`.
595
+ All other non-empty turns shall use `callJudge` only to choose one of the FSM's
596
+ event kinds and non-text routing fields, or no FSM action.
597
+ The classifier prompt shall include the exact, unmodified `turn.text` in a
598
+ clearly labelled Boss-message block so the judge can make that choice. Omitting
599
+ the message makes a parked-state classifier unable to distinguish an answer,
600
+ a fresh directive, and no action; including it does not authorize the judge to
601
+ rewrite the runtime-owned textual payload fields.
602
+ For `BOSS_INTENT` and `BOSS_INTERRUPT`, the runtime shall attach the exact
603
+ original text as `bossIntent`; for `BOSS_REPLY`, it shall attach the exact
604
+ original text as `answer`.
605
+ The classifier prompt shall neither request nor accept a copy of those fields,
606
+ and classifier-authored paraphrases shall never become machine context.
607
+ Empty or whitespace-only text produces no event, judge call, Captain call,
608
+ player call, status emission, or FSM transition; its received and settled
609
+ session-trace events are still emitted.
610
+
611
+ The classifier prompt shall demand JSON against the FSM's typed event union and any state-specific Boss input contract, including non-text routing payload fields required for each event but excluding the runtime-owned textual fields above.
612
+ Fields the FSM's event union declares optional shall stay optional in the classifier contract and the reply parser; the classifier shall not promote them to required.
613
+ The runtime shall parse the judge reply tolerantly before validating the
614
+ event. It shall recover the intended JSON object from surrounding prose or a
615
+ Markdown fence, ignore earlier non-JSON bracketed prose, remove a trailing
616
+ comma before a closing brace or bracket, and complete a truncated
617
+ unterminated string or unclosed object/array. When several values are
618
+ recoverable, it shall choose the first object in document order, preferring a
619
+ strict parse at each candidate position before repairing that same candidate.
620
+ For each opening-brace position, first scan strings and nesting to find that
621
+ candidate's earliest balanced closing boundary. Both the strict parse and the
622
+ trailing-comma repair shall operate on only that bounded substring. If no
623
+ closing boundary exists, repair may complete the unterminated suffix. The
624
+ implementation shall never repair the entire remaining document after a
625
+ balanced candidate, because later prose or a later clean object would make the
626
+ earlier repair fail. Advance to the next opening brace only after strict and
627
+ repaired parsing of the current bounded candidate both fail; an earlier
628
+ repairable object therefore wins over every later strict object.
629
+ When no object is recoverable or the recovered event/payload is invalid, the
630
+ runtime shall emit exactly one status and send no FSM event; a malformed
631
+ classification is recoverable control input, not a public boundary rejection.
632
+ If a recovered `BOSS_REPLY` names no question that is currently pending, it is
633
+ such a malformed classification: emit the one recovery status, send no event,
634
+ leave the actor unchanged, and return `no-action` after emissions drain.
635
+ Host-owned runtime options, player bindings, and enabled-playbook catalogs are
636
+ not Boss-event payload. The classifier schema and parser shall not invite or
637
+ accept them, and classified prose shall never overwrite their machine context.
638
+ Every recovered classifier object shall have exactly `type` plus the declared
639
+ non-text routing keys for its selected event arm. Extra own keys, including a
640
+ classifier-authored `bossIntent` or `answer`, reject the classification; the
641
+ parser shall not accept and discard injected catalog, option, state, or
642
+ routing fields.
643
+ `NO_ACTION` in particular is exactly `{ type: 'NO_ACTION' }`.
644
+ A valid `NO_ACTION` returns `no-action` without an invalid-classification
645
+ status and leaves the actor untouched. It is a successful classifier choice,
646
+ not the same parser result as malformed or unrecoverable classifier output.
647
+ After any successful classifier call drains, re-check the active Boss signal
648
+ before reconstructing a terminal actor or sending the selected event. If it
649
+ aborted while the classifier finish emission was pending, return and trace the
650
+ same structured `aborted` result against the unchanged actor.
651
+ When the FSM supports a Boss-reply suspension state, the prompt shall inspect
652
+ the actor snapshot context and include each exact pending Boss question,
653
+ question id, and asking player so the judge can distinguish a reply from a
654
+ fresh directive. With one pending question, a classified `BOSS_REPLY` that
655
+ omits its optional id shall be filled with that sole id. With several pending
656
+ questions, the classifier shall require a known id. A reply shall re-enter only
657
+ its recorded resume state and preserve the original intent, plan, prior child
658
+ results, and Q+A continuation context.
659
+ The classifier-facing pending-question block contains only `questionId`,
660
+ `player`, and `question`. Internal `resumeStateId`, source-item identity, and
661
+ other machine-routing fields remain authoritative in snapshot context and
662
+ shall not be serialized into the judge prompt.
663
+ The allowed fresh directives while parked include every applicable root entry
664
+ event and `BOSS_INTERRUPT`; accepting one shall abandon and clear the pending
665
+ question and reply context before new work begins.
161
666
 
162
667
  A playbook runtime shall not define slash-prefix commands for states or features inside that playbook.
163
668
  The `/command` namespace is reserved for host-level or playbook-selection UX before a turn reaches `handleBossInput`.
164
- If a host forwards text beginning with `/` to `handleBossInput`, the runtime treats it as ordinary Boss text and classifies it through `callJudge`.
669
+ If a host forwards text beginning with `/` to `handleBossInput`, the runtime treats it as ordinary Boss text and maps it through the same deterministic-or-classified Boss-event rules.
165
670
 
166
671
  Hosts that receive structured control input shall resolve host-level concerns before choosing a playbook runtime.
167
672
  Once they call `handleBossInput`, they shall pass the Boss content as text and shall not pre-classify in-playbook FSM events or rely on slash forms as a runtime protocol.
168
673
 
169
674
  `BOSS_INTERRUPT` (or the FSM's equivalent explicit-state-jump event) is reached only by the judge choosing it and supplying its required target payload.
170
- It is *not* an abort surface; aborts go through the abort signal and the strategies in §Abort.
675
+ It is _not_ an abort surface; aborts go through the abort signal and the strategies in §Abort.
171
676
  Hosts where the abort signal is terminal (e.g., SIGINT runs shutdown) shall not route abort to `BOSS_INTERRUPT`.
172
677
 
173
678
  ## Captain adjudication
174
679
 
175
- After a player call returns, the runtime shall coerce `result.finalText` into one of the **per-state** `invoke.input.result` keys.
680
+ After a direct Captain or delegated player call returns, the runtime shall
681
+ coerce `result.finalText` into one of the **per-state**
682
+ `invoke.input.result` keys.
176
683
  It shall also extract any payload fields the state's `result` description names as required.
684
+ Required-field extraction shall recognize both an exact backticked property
685
+ name such as `` `question` `` and the standard annotated form
686
+ `` `question: <verbatim question text>` ``; in either form only `question` is
687
+ the JSON property name.
688
+ Extraction is limited to the description's explicit `Output shall include`
689
+ clause (or equivalent typed output metadata). Backticked prose before that
690
+ clause can name statuses, guards, or concepts such as `ok`, `aborted`, and
691
+ `error`; those names are not output properties and shall never become required
692
+ judge fields.
693
+ For a direct Captain result, `question` and `response` are human-presentation
694
+ fields owned by the visible call rather than fields authored by the hidden
695
+ judge.
696
+ The adjudicator shall select the guard and supply only other structural fields
697
+ required by that guard.
698
+ After validating that selection, the runtime shall inject the exact non-empty
699
+ `CaptainResult.finalText` as the selected output's `question` or `response`.
700
+ It shall reject a judge reply that supplies either presentation field as an
701
+ undeclared extra key, so hidden adjudication cannot replace, paraphrase, or
702
+ decorate prose Boss already saw.
703
+ Delegated-player adjudication retains extraction of every required field from
704
+ the judge reply, including a player-authored Boss question.
705
+ The adjudicator shall use the same document-order tolerant JSON recovery as
706
+ the Boss classifier. Unlike invalid classification, a reply from which no
707
+ object can be recovered, an undeclared guard, or a missing required field is a
708
+ control-plane error and shall throw after the invocation reaches its FSM error
709
+ path and ordered emissions drain.
177
710
 
178
711
  Two default adjudication strategies, in selection order:
179
712
 
180
713
  - **LLM-judge** (default): construct a fresh prompt for `callJudge` that
181
- names the source item's player, includes the player's verbatim output,
714
+ names the source item's actor (and delegated player where applicable),
715
+ includes the actor's verbatim output,
182
716
  lists the `result` keys with their descriptions, and demands a JSON
183
- `{ guard, …payloadFields }` answer keyed to exactly one of the
184
- declared guards. The judge prompt shall not interpret the player's
717
+ `{ guard, …structuralPayloadFields }` answer keyed to exactly one of the
718
+ declared guards, excluding the runtime-owned direct-Captain `question` and
719
+ `response` fields above. The judge prompt shall not interpret the player's
185
720
  output, paraphrase it, or alter the FSM's `result` text — it carries
186
721
  the description verbatim.
187
- - **Marker-parse** (alternative): a deterministic parser that scans the
188
- player output for a terminal control line such as
722
+ - **Marker-parse** (delegated-player alternative): a deterministic parser that
723
+ scans the player output for a terminal control line such as
189
724
  `FSM-RESULT: { "guard": "...", ... }`. Useful when player adapters can
190
725
  be steered to emit structured trailers and the operator wants to avoid
191
726
  the extra LLM call.
192
727
 
193
- The linker may select different strategies per state; the default is **LLM-judge for every state**.
728
+ The linker may select different strategies per delegated-player state; the
729
+ default is **LLM-judge for every state**. Direct-Captain states shall use the
730
+ LLM judge so their visible prose remains human-readable and carries no marker
731
+ or control JSON. Their adjudicator call uses purpose
732
+ `captain-output-adjudication` and remains hidden at the host adapter.
733
+
734
+ When the direct Captain result selects a terminal `response`, the exact
735
+ already-visible `CaptainResult.finalText` is the machine response and Boss
736
+ presentation. The linked
737
+ runtime shall not make a second visible Captain call or expose the hidden
738
+ structured adjudication merely to present the same response.
194
739
 
195
740
  The adjudicator shall fail loudly on:
196
741
 
@@ -198,49 +743,444 @@ The adjudicator shall fail loudly on:
198
743
  - A missing payload field the state's `result` description requires,
199
744
  - An empty / malformed response.
200
745
 
746
+ These cases shall remain distinguishable in the thrown error: malformed JSON
747
+ recovery shall identify the missing JSON object, an unknown selection shall
748
+ identify an undeclared guard, and an incomplete selection shall identify the
749
+ missing required field. A generic “no declared guard selected” error for all
750
+ three cases is nonconformant.
751
+
201
752
  Adjudicator failures are control-plane errors.
202
753
  The runtime shall propagate them by throwing out of `handleBossInput` after attempting cleanup.
203
754
  The host adapter surfaces the throw on its control-plane channel (cligent surfaces such throws as `runtime_error` per [TMUX-025](https://github.com/sublang-ai/cligent/blob/main/specs/user/tmux-play.md#tmux-025)).
204
755
  The host's player-result channels (`player_finished` and equivalents) are reserved for failures the player itself produced; the host emits them when `callPlayer` resolves with `status !== 'ok'`.
756
+ Captain call failures stay on the Captain/control boundary and shall not be
757
+ reported as player failures.
758
+ Because XState still needs the invoked promise to settle, the linked runtime
759
+ shall latch an adjudicator, actor-output JSON-validation, or nested-boundary
760
+ control error outside machine context, allow the invocation's `onError` path to
761
+ reach quiescence, drain all emissions, and then reject the public runtime
762
+ method with that original error. It shall not return such a failure as a
763
+ recoverable `{ outcome: 'failed' }` workflow result.
764
+ The first latched non-abort control error takes precedence over a coincident
765
+ boundary-signal abort. Read and clear the latch only in the public boundary's
766
+ `finally` cleanup after XState and emissions have settled, so it cannot leak
767
+ into a later Boss turn or be erased before rejection.
768
+ An `AbortError`-named transport, validation, or trace-sink failure is still a
769
+ non-abort control error unless it is causally identical to the applicable
770
+ signal reason. Error names shall never change original-error or first-latch
771
+ precedence.
772
+ When a host port or structured-result validator fails after a call-start
773
+ boundary, latch that original error before attempting the required finish
774
+ trace. If the finish sink records the event and then rejects, do not emit a
775
+ second finish and do not let the sink failure replace the earlier control
776
+ error returned by the public boundary; retain the sink failure only as
777
+ independent cleanup evidence.
778
+
779
+ ## Script execution
780
+
781
+ Where the FSM declares the typed `script` actor from
782
+ [gears2fsm "Setup"](gears2fsm.md#setup), the linker shall provide its
783
+ implementation inside the emitted module.
784
+ A script invocation is the one actor kind that runs without any agent:
785
+ it makes no `callPlayer`, `callCaptain`, or `callJudge` call and needs no
786
+ adjudication.
787
+
788
+ The provided actor shall:
789
+
790
+ - Execute `input.command` verbatim through the platform's POSIX shell
791
+ (`sh -c`), with the working directory taken from the emitted
792
+ `PlaybookRuntimeOptions.cwd` when the caller supplies it, else the process
793
+ working directory. The linker shall declare the optional `cwd` option on the
794
+ emitted options interface whenever the FSM contains a script state.
795
+ - Resolve deterministically from the child's exit status: status zero resolves
796
+ `{ guard: <first declared guard>, exitStatus: 0 }`; any nonzero status
797
+ resolves the second declared guard with that status. Guard selection is
798
+ mechanical; the runtime shall not route script output through the judge.
799
+ - Reject only when the command cannot be spawned at all, routing through the
800
+ state's ordinary `onError` path.
801
+ - Honor the active turn's abort signal by terminating the child process and
802
+ rejecting per §Abort.
803
+ - Emit, after the child settles and before the invocation resolves, one status
804
+ line `Executed script for <stateId> (exit <status>).` and one telemetry
805
+ event under topic `playbook.script` with payload
806
+ `{ stateId, sourceItem, exitStatus }`, through the ordinary serialized
807
+ emission channel.
808
+
809
+ Script execution emits no `*.call.*` trace pair: the surrounding FSM
810
+ transition trace and the `playbook.script` telemetry are its record, so trace
811
+ schema consumers see no new event types.
812
+ Script stdout and stderr are not workflow data: the runtime shall not place
813
+ them in machine context, prompts, or trace payloads.
814
+
815
+ ## Nested playbook bridge
816
+
817
+ Where the FSM declares the typed `playbook` actor from
818
+ [gears2fsm](gears2fsm.md#nested-playbook-calls), the linked runtime shall provide
819
+ it with the shared `createNestedPlaybookBridge(...).actorLogic`; it shall not
820
+ regenerate a second pending-call, identity-validation, or abort-cleanup
821
+ substrate inside each linked artifact.
822
+ Instantiate the generic bridge with the FSM-exported `PlaybookInput` type so
823
+ XState `.provide(...)` receives the exact declared actor input rather than a
824
+ structurally similar local type.
825
+ Construct one bridge per runtime and wire every integration hook: allocate ids
826
+ with `nextCallId`; return the currently active public-boundary signal from
827
+ `getBoundarySignal`; bind `resumePlaybookCall.signal` before settling the
828
+ deferred actor through `bindResumeSignal`; enqueue the exact start/finish trace
829
+ through `emitStarted` / `emitFinished`; drain the global emission queue through
830
+ `drain`; latch the original control error through `onControlPlaneError`; and
831
+ retain any cleanup/observer failure through `onBackgroundError` for the next
832
+ public boundary or disposal rejection. The runtime shall not leave these
833
+ optional API hooks unwired merely because their TypeScript properties are
834
+ optional for simpler bridge consumers.
835
+ On invocation the bridge allocates a runtime-local call id, traces the start,
836
+ and calls `PlaybookPorts.callPlaybook` with the composed target/text and the
837
+ bridge signal combined from the XState invocation lifetime, the active public
838
+ boundary, and the bridge's own disposal controller.
839
+
840
+ For a literal invocation, target and text retain their existing static/composed
841
+ values. For a dynamic invocation, the bridge shall use the evaluated
842
+ `PlaybookInput.playbookId` and `PlaybookInput.text` values, require both to be
843
+ strings with non-empty target and text, and preserve the exact resolved values in the
844
+ request and trace. The linker shall preserve the FSM's static
845
+ `playbookIdContext` and `textContext` metadata for conformance; it shall not
846
+ parse function source, treat either metadata name as the runtime value, or
847
+ freeze a dynamic call to the value observed during artifact inspection.
848
+
849
+ If the port returns `state: 'settled'`, the bridge validates the result,
850
+ emits and drains `playbook.call.finished`, then resolves successful output or
851
+ rejects an aborted/error result.
852
+ If the port returns `state: 'suspended'`, the bridge records one pending call
853
+ and awaits a runtime-owned deferred result.
854
+ Only after that pending record exists may the drive boundary treat the call
855
+ state's `playbook.suspended` tag as quiescent.
856
+ One runtime supports at most one pending child call; a second shall reject.
857
+ The pending record shall also retain the call-start `turnId`. A resumed finish
858
+ and every parent transition, Captain reassessment, and status caused by that
859
+ return shall use this retained id, not an absent or newly allocated
860
+ current-turn value. The finish callback shall receive or close over that stored
861
+ id rather than read a mutable global turn id at resume time.
862
+ The bridge shall strictly validate the start discriminant, non-empty suspended
863
+ child session id, settled target identity, optional state descriptor,
864
+ normalized error, and JSON-safe output. A malformed start, malformed result,
865
+ identity mismatch, or non-JSON value is a control-plane error. Once a start
866
+ trace exists, every thrown port, validation failure, immediate result,
867
+ suspension resume, invocation abort, and disposal path shall emit and drain
868
+ exactly one matching finish trace; malformed data shall neither create a
869
+ pending identity nor be reassessed as ordinary child evidence.
870
+ The bridge shall detach and recursively freeze a validated start/result before
871
+ tracing it or delivering it to the FSM, so caller mutation after port
872
+ resolution cannot alter identity, evidence, or trace payloads. A non-abort
873
+ `callPlaybook` throw/rejection is a control-plane failure: pair its finish,
874
+ latch and rethrow the original error, and take the FSM fallback error path. A
875
+ rejection caused by the combined abort signal remains an authored `aborted`
876
+ child result.
877
+ The optional output field may be absent from an otherwise valid successful
878
+ child result. Generated event and trace descriptors shall omit an absent or
879
+ `undefined` output instead of attempting to snapshot it as a JSON value.
880
+ When cancellation wins while the host's opening promise is still pending, the
881
+ shared bridge shall retain and drain that exact promise before emitting the
882
+ matching finish boundary. It shall ignore an abort-reason rejection from that
883
+ opening promise, surface any other late rejection as a control-plane cleanup
884
+ failure, and recover a child session identity from a late resolved start when
885
+ available. Generated runtimes shall pass the host port directly to the shared
886
+ bridge rather than recreate this opening-promise drainage locally.
887
+ In particular, aborting a public turn during that opening promise shall abort
888
+ the combined bridge signal, wait for opening cleanup and the paired finish,
889
+ let the promise actor reach its `onError` quiescent state, and only then return
890
+ an aborted run result. It shall neither hang waiting for a child-resume path
891
+ that was never registered nor return while the opening promise or finish
892
+ emission remains live.
893
+ The pending record shall retain a one-shot invocation-signal listener. If the
894
+ call state is stopped, that listener shall settle and clear the deferred call
895
+ as an aborted `NestedPlaybookCallError`, drain the matching finish boundary after
896
+ host abort cleanup, and make a later nested invocation possible; it shall not
897
+ leave a permanently pending record merely because XState stopped observing the
898
+ promise actor.
899
+
900
+ `resumePlaybookCall` shall accept only the matching pending call id, target
901
+ playbook id, and child session id; bind its new turn signal for work resumed in
902
+ the parent; emit and drain the call-finish trace; settle the bridge deferred;
903
+ and use XState `waitFor` to drive the parent to its next
904
+ quiescent, suspended, failed, aborted, or terminal result.
905
+ An `ok` result resolves the actor and reaches `invoke.onDone`; `aborted` and
906
+ `error` results reject it and reach `invoke.onError`.
907
+ The rejection shall be an `Error` whose public readonly `result` property is
908
+ the exact normalized `PlaybookCallResult`; throwing the result object directly
909
+ or discarding its status prevents the FSM from distinguishing abort from
910
+ failure during recovery.
911
+ Unknown, duplicate, or stale call ids reject without changing actor state.
912
+ The finish trace shall therefore precede any parent FSM transition caused by
913
+ the child return.
914
+ The host independently validates every evaluated target against its enabled
915
+ registry; linker-time metadata is not authorization to call a target.
916
+
917
+ Disposal shall settle an outstanding call as aborted and drain its finish
918
+ trace before `session.disposed`.
919
+ If registered child abort cleanup rejects, the bridge shall emit the paired
920
+ finish with an error result and reject `abortPending` or disposal with that
921
+ original cleanup error; it shall not swallow the failure merely because the
922
+ promise actor also observes a `NestedPlaybookCallError`. Parent disposal shall
923
+ still drain, emit its one `session.disposed` boundary, and clear the bound
924
+ session before rejecting with that preserved cleanup error.
925
+ Child output and errors must be JSON-safe; a non-JSON-safe result is a
926
+ control-plane error.
205
927
 
206
928
  ## Session lifecycle
207
929
 
208
930
  The `PlaybookRuntime` shall:
209
931
 
210
- - In `init`, construct the XState actor with FSM `input` derived from
211
- `options`. The actor is session-scoped, not turn-scoped. Subscribe to
212
- actor snapshots so each transition can be surfaced via `emitStatus`
213
- and `emitTelemetry` before the next event fires. Start the actor.
932
+ - Reject use before `init`, a second active turn or resume, and re-initializing
933
+ a live session. `handleBossInput` and `resumePlaybookCall` share one active
934
+ turn sentinel; neither may overlap the other, and disposal shall not race a
935
+ live boundary. A dispose request made during an active public boundary shall
936
+ reject without beginning teardown. Idle concurrent dispose requests shall
937
+ share one disposal promise; later calls after disposal shall return that
938
+ settled disposal outcome without emitting another boundary. Once disposal
939
+ begins, no new turn or resume may start.
940
+ Disposal requested during initialization shall retain one teardown promise,
941
+ wait for initialization's success or failure cleanup, and emit at most one
942
+ `session.disposed` boundary. Disposal before initialization is terminal and
943
+ coalesced: later initialization rejects and every later disposal call
944
+ returns the first retained promise.
945
+ Represent in-flight initialization with a cleanup-complete latch resolved by
946
+ `init`'s outer `finally`, after either successful startup or the complete
947
+ failed-start cleanup. Do not expose the fallible inner startup promise as
948
+ that latch: it rejects before the outer cleanup and lets concurrent disposal
949
+ race the cleanup's own `session.disposed` attempt.
950
+ Put session validation and snapshotting, bridge/actor construction, initial
951
+ state reads, and startup emissions inside that guarded outer `try`; none may
952
+ throw before the cleanup-complete latch's `finally` can resolve. A rejected
953
+ session identity must not leave later disposal waiting forever.
954
+ The generated `dispose` method shall not be declared `async`, because an
955
+ async wrapper returns a distinct promise and breaks identity coalescing; it
956
+ shall return the retained teardown promise directly and use
957
+ `Promise.reject(...)` for precondition failures.
958
+
959
+ - In `init`, bind the immutable `PlaybookSession`, emit
960
+ `session.started` with the initial normalized state descriptor, and
961
+ construct the XState actor with FSM `input` derived
962
+ from `options`. The actor is session-scoped, not turn-scoped. Use XState v5's
963
+ public actor inspection `@xstate.snapshot` event for the root actor so each
964
+ transition's triggering event and snapshot can be surfaced via `emitStatus`
965
+ and `emitTelemetry` before the next event fires; do not consult private actor
966
+ nodes or infer the event later from context. Filter inspection events by
967
+ `inspectionEvent.actorRef === rootActor`, not merely by the actor-system root
968
+ id, so promise-child snapshots are not emitted as root FSM transitions. The
969
+ inspection callback shall only validate and synchronously enqueue emission
970
+ work, catching validation/enqueue failures into the control/background-error
971
+ latch; it shall not let an exception escape or call an async port directly.
972
+ Its transition `event` field shall be a detached JSON-safe descriptor, never
973
+ the raw XState inspection event. Preserve the string `type` (or use
974
+ `unknown` when absent); copy only declared Boss-union payload fields and a
975
+ validated actor `output`, and normalize an `error` member before inclusion.
976
+ Omit `input`, `actorId`, system/ref data, and every other XState-internal
977
+ field even when it happens to be JSON-safe, so `xstate.init.input` cannot
978
+ leak the host catalog into transition telemetry. In particular, do not call
979
+ `snapshotJsonValue(event)` on an `xstate.error.actor.*` event that contains a
980
+ raw `Error`.
981
+ Construct the actor without starting it, read its public initial snapshot,
982
+ emit and drain `session.started`, and only then call `actor.start()`. The
983
+ initial inspection-driven transition/status emissions shall not precede the
984
+ session-start trace. Have any actor-construction helper return the actor and
985
+ assign it at the call site; TypeScript does not narrow a captured optional
986
+ actor variable from assignment hidden inside a helper. Retain a non-optional
987
+ local actor reference across terminal reconstruction and event sending.
988
+ An actor-construction helper may read the already-bound immutable session
989
+ directly for machine input such as `session.playbookId`, but it shall not
990
+ call a lifecycle assertion that also requires the actor to exist. The actor
991
+ does not exist until that helper returns, so coupling session access to actor
992
+ availability makes every valid `init` fail before construction completes.
993
+ Generated code shall pass the repository's full strict `tsc` build with no
994
+ unused helper or destructured parameter, not only a transpile-only or
995
+ target-local syntax check.
996
+ For the default Captain runtime, the initial quiescent `ready` snapshot may
997
+ emit the ordinary structured transition trace and telemetry, but it is not a
998
+ Boss-relevant transition and shall emit no human status. Any initial
999
+ transition-trace or telemetry sink failure is part of `init`: initialization
1000
+ shall reject, stop the actor, and perform the failed-start cleanup below
1001
+ rather than swallowing it as a later background error.
1002
+ Where the FSM input declares `selfPlaybookId`, seed it from the immutable
1003
+ `session.playbookId`; do not expose a caller option or reuse a working leaf's
1004
+ `stateId` as the self-call identity.
1005
+ - If initialization fails after attempting `session.started`, stop the actor,
1006
+ abort/drain nested and host work, and make one best-effort
1007
+ `session.disposed` attempt before clearing the bound session. Preserve the
1008
+ original initialization error if cleanup or disposal emission also fails.
1009
+ Suppress root inspection emissions before stopping the failed actor because
1010
+ XState emits a stop snapshot; that teardown snapshot shall not retry a
1011
+ transition/status sink that already failed initialization. Reset the
1012
+ inspection gate, queues, error latches, prior state, and all per-session
1013
+ sequence counters so a permitted retry starts with trace sequence `1`.
214
1014
  - Per `handleBossInput`:
215
- 1. Classify `turn.text` through the Boss-event mapping.
216
- If it produces no event, return after draining any port emissions.
217
- 2. If the actor is in a `final` state, dispose and reconstruct it —
218
- `final` is terminal and cannot accept new events.
219
- 3. Send the classified event to the actor.
220
- 4. **Drive to quiescence**: each time the actor invokes its `captain`
221
- actor, await the invoke's input, build a player prompt, call
222
- `callPlayer`, adjudicate, and resolve the invoke. Repeat until the
223
- actor's snapshot value is a state that takes a Boss event
224
- (typically `ready` or `failed`) or a `final` state.
225
- - In `dispose`, stop the actor and drain pending port emissions.
1015
+ 1. Allocate a runtime-local turn id and trace the exact Boss text.
1016
+ 2. Map `turn.text` through the Boss-event mapping, using deterministic exact
1017
+ entry where applicable and classification otherwise.
1018
+ If mapping produces no event, return after draining any port emissions.
1019
+ If the classifier port rejects, emit and drain the Boss-settled error
1020
+ boundary, send no event, leave the actor unchanged (including a terminal
1021
+ actor), and reject the original error. If that rejection is caused by the
1022
+ active Boss abort signal, return and trace the same structured `aborted`
1023
+ result instead of tracing `no-action`. If the port resolves but its reply
1024
+ cannot be recovered or validated, emit the one recovery status required
1025
+ by §Boss-event mapping, send no event, leave the actor unchanged, and
1026
+ return `no-action` after the ordinary settled boundary drains.
1027
+ 3. Only after classification produces a real event, if the actor is in a
1028
+ `final` state, dispose and reconstruct it — `final` is terminal and cannot
1029
+ accept new events. `NO_ACTION`, classifier rejection, and malformed
1030
+ classification shall leave a terminal actor untouched.
1031
+ 4. Bind the active public-boundary signal and send the classified event to
1032
+ the actor.
1033
+ 5. **Drive to quiescence**: provide each invoked actor according to its
1034
+ declared kind. For `player`, build a player prompt, call `callPlayer`,
1035
+ adjudicate, and resolve the invoke. For `captain`, build a direct Captain
1036
+ prompt, call `callCaptain` visibly, adjudicate through the shared hidden
1037
+ judge path, and resolve the invoke. For `playbook`, use §Nested playbook
1038
+ bridge. For `script`, use §Script execution — no port call and no
1039
+ adjudication. Parallel regions may run distinct resolved players independently;
1040
+ Captain and judge work remains serialized by the shared host queue. Use
1041
+ XState `waitFor` over public tags/status until no `playbook.busy` state is
1042
+ active, a registered child call is suspended, or the actor is
1043
+ terminal/error. Pass `pendingCalls: nestedBridge` so a suspended tag is
1044
+ quiescent only after its child identity exists. Under natural rejection,
1045
+ do not pass the already-aborted public turn signal as wait cancellation:
1046
+ it has already been combined into the invoked boundary, and the runtime
1047
+ must now wait for XState's `onError` transition and quiescence.
1048
+ 6. Return a structured `PlaybookRunResult` after all in-flight calls and
1049
+ ordered emissions caused by the turn drain.
1050
+ - Per `resumePlaybookCall`, follow §Nested playbook bridge and return the same
1051
+ structured run-result boundary without classifying new Boss text. Drain the
1052
+ transition/status/telemetry queue before returning, just as
1053
+ `handleBossInput` does. A resume shall not allocate a new Boss-input
1054
+ `turnId`; retain the original call-start turn id for its matching finish and
1055
+ for the parent continuation caused by that return. Every success and
1056
+ exceptional path shall drain ordered emissions, select the first latched
1057
+ non-abort control error before considering abort, and clear its boundary
1058
+ latches in `finally`, so a failed resume cannot leak an emission error into a
1059
+ later turn.
1060
+ A resume is not a Boss-input turn and shall emit neither
1061
+ `boss.input.received` nor `boss.input.settled`; the structured result is the
1062
+ method return. Reusing the originating turn id on the child finish and
1063
+ continuation emissions does not create a second Boss trace pair.
1064
+ This quiescence and drain path is mandatory even when
1065
+ `nestedBridge.resume(...)` rejects: capture that operation error, allow the
1066
+ promise actor's `onError` transition to settle, and select the first latched
1067
+ control error only after all ordered emissions have drained.
1068
+ - In `dispose`, capture the final public state and stop the root actor before
1069
+ settling or aborting a suspended nested bridge, so the bridge rejection
1070
+ cannot reenter the FSM and start new actor work during disposal. Then drain
1071
+ pending port emissions and every in-flight Captain/player/judge/child opening, emit
1072
+ `session.disposed` with the final descriptor, and discard player resume
1073
+ tokens. Host child abort cleanup and child `session.disposed` shall drain
1074
+ before the parent call finish, which shall drain before parent
1075
+ `session.disposed`. Use cleanup/finally structure so a bridge or emission
1076
+ failure cannot skip the parent disposal boundary or leave the runtime bound.
226
1077
 
227
1078
  The actor's `lastError` field shall be surfaced via `emitStatus` when the machine enters its `failed` state.
1079
+ For the default Captain runtime, an initial `ready` state and a terminal `done`
1080
+ state shall not emit human status. The terminal response is already visible
1081
+ Captain prose; a synthetic “entered done” message would present it twice.
1082
+ Structured transition trace and telemetry still apply to both states.
1083
+ Every provided actor boundary shall first drain the queued state-entry
1084
+ transition/status/telemetry caused by entering its working leaf, so a call's
1085
+ `*.started` trace cannot overtake the transition that explains it. Every public
1086
+ runtime method shall drain that queue before it resolves or rejects.
1087
+ This initial `await drain()` is required inside each provided `fromPromise`
1088
+ body: XState may begin that body before publishing the root snapshot, and the
1089
+ await yields so the synchronous inspection callback can enqueue the entering
1090
+ transition first.
1091
+
1092
+ If a `*.call.started` trace records and then its sink rejects, no host call may
1093
+ begin. The runtime shall still enqueue exactly one synthetic paired
1094
+ `*.call.finished` trace with `status: 'error'`, preserving the original call
1095
+ id, turn id, actor visibility, state/source identity, and prompt or request
1096
+ metadata from the start boundary. It shall then follow the same latched
1097
+ control-error, FSM settlement, and ordered-drain path as any other call-start
1098
+ failure; the synthetic finish must not replace the original sink error.
1099
+
1100
+ ## Parked-session snapshot (optional)
1101
+
1102
+ A linked runtime may implement the optional durable-session capability of
1103
+ `@sublang/playbook/runtime` — `exportSnapshot()` and
1104
+ `restore(session, snapshot)` — so a host can persist a parked session and
1105
+ rehydrate it in a later process (DR-014). A runtime that implements either
1106
+ member shall implement both. When generated for a runtime whose host needs
1107
+ durability, the pair shall behave as follows.
1108
+
1109
+ `exportSnapshot()` shall return `undefined` unless the runtime is at a safe
1110
+ capture point: initialized, not disposing or disposed, no active
1111
+ `handleBossInput`/`resumePlaybookCall` boundary, no pending nested playbook
1112
+ call, and the root actor at a quiescent state with actor status `active`.
1113
+ At a safe capture point it shall return a JSON-safe
1114
+ `PlaybookRuntimeSnapshot` carrying:
1115
+
1116
+ - `schemaVersion`: literal `1`.
1117
+ - `playbookId`: the bound session's playbook id.
1118
+ - `machine`: the root actor's `getPersistedSnapshot()` result, passed
1119
+ through the shared JSON detachment with any raw `Error` context value
1120
+ (for example FSM `lastError`) normalized to `{ name, message, stack? }`
1121
+ first. The value is opaque to hosts.
1122
+ - `playerResumeTokens`: the resume-token map as a plain object
1123
+ (§PlaybookPorts contract).
1124
+ - `sequences`: the live `trace`, `turn`, `judgeCall`, `playerCall`, and
1125
+ `playbookCall` counters.
1126
+ - `state`: the current normalized state descriptor.
1127
+ - `pendingBossQuestions`: the pending Boss question(s) from FSM context as
1128
+ a list of `{ questionId, player, question, sourceItem? }`, empty when the
1129
+ parked state awaits no reply. This list exists so hosts can surface the
1130
+ question without parsing status lines or telemetry.
1131
+
1132
+ `restore(session, snapshot)` is an alternative to `init` under the same
1133
+ lifecycle guards (§Session lifecycle): it shall reject when already
1134
+ initialized, disposing, or disposed, and shall validate
1135
+ `snapshot.schemaVersion` and that `snapshot.playbookId` equals
1136
+ `session.playbookId` before touching state.
1137
+ The host supplies the same immutable `PlaybookSession` identity the
1138
+ snapshot was exported under and recreates the runtime through the same
1139
+ factory with equivalent options; the runtime does not diff options, and
1140
+ module identity — that the factory constructing this runtime still
1141
+ belongs to the snapshot's playbook — is likewise the host's check to
1142
+ make before calling `restore`.
1143
+ `restore` shall bind the session, restore the resume-token map, the
1144
+ sequence counters, and the prior-state descriptor from the snapshot,
1145
+ construct the actor with the persisted `machine` snapshot, and start it
1146
+ with root inspection emissions suppressed so rehydration emits no
1147
+ `session.started` trace, no transition trace, and no human status — the
1148
+ session already started, and the next public boundary continues the
1149
+ contiguous trace sequence. After start, a restored actor whose status is
1150
+ not `active` or whose state descriptor cannot be normalized shall fail
1151
+ `restore` through the same failed-start cleanup path as `init`.
1152
+ A restore failure shall leave the runtime unbound so `dispose` remains
1153
+ callable and terminal.
228
1154
 
229
1155
  ## Abort
230
1156
 
231
1157
  `handleBossInput.signal` is the abort surface.
232
- The runtime shall honor it at every `callPlayer`/`callJudge` and at every poll between transitions.
233
- On abort, the runtime shall drive the actor to a quiescent state before returning from the turn.
1158
+ The runtime shall honor it at every `callPlayer`/`callCaptain`/`callJudge` and
1159
+ at every poll between transitions.
1160
+ Each provided Captain, player, judge, or nested-playbook boundary shall receive
1161
+ a signal combined from its XState invocation-lifetime signal and the currently
1162
+ active `handleBossInput` or `resumePlaybookCall` signal (for example with
1163
+ the shared `combineAbortSignals`). Classify a rejection as cancellation by its
1164
+ causal identity with the applicable signal reason, not by an `AbortError` name
1165
+ or by observing only that the signal is also aborted. Signals may carry an
1166
+ ordinary `Error`, while a distinct transport or sink failure that occurs after
1167
+ abort remains a non-abort control error and takes precedence. On abort, the
1168
+ runtime shall not merely race the imperative
1169
+ wait and return while an invocation remains live: it shall let the selected
1170
+ rejection path settle and drive the actor to a quiescent state before returning
1171
+ from the turn. No trace, status, state, or call completion caused by that turn
1172
+ may appear after the public method returns.
234
1173
  Three strategies are permitted; the linker selects per FSM:
235
1174
 
236
- - **Natural rejection** — the runtime's Captain actor (e.g.,
1175
+ - **Natural rejection** — the runtime's Captain or player actor (e.g.,
237
1176
  `fromPromise`) ends the invocation by rejecting, and the FSM routes
238
1177
  the rejection through `onError` to a quiescent sink. The cancelled
239
- port call may *itself* reject, or it may resolve with
240
- `PlayerResult { status: 'aborted' | 'error' }` that the runtime
241
- inspects and converts into a Captain-actor rejection. Either shape
242
- is permitted the contract is on the Captain-actor boundary, not on
243
- the port's promise behavior. Preferred when every Captain-invoking
1178
+ port call may _itself_ reject, or it may resolve with
1179
+ `PlayerResult` or `CaptainResult` with
1180
+ `{ status: 'aborted' | 'error' }` that the runtime
1181
+ inspects and converts into an actor rejection. Either shape
1182
+ is permitted — the contract is on the actor boundary, not on
1183
+ the port's promise behavior. Preferred when every Captain- or player-invoking
244
1184
  state's `onError` lands somewhere quiescent; the FSM's own error
245
1185
  wiring is the abort path.
246
1186
  - **Synthetic pre-emption to a quiescent target** — send the FSM's
@@ -249,7 +1189,7 @@ Three strategies are permitted; the linker selects per FSM:
249
1189
  The runtime shall not pick the active state as the target:
250
1190
  `gears2fsm.md` prescribes `reenter: true` for `bossInterrupts`, so
251
1191
  re-entering the active state restarts its `invoke` and spawns a
252
- fresh player call.
1192
+ fresh agent call.
253
1193
  - **Programmatic stop** — `actor.stop()` and report the turn as aborted
254
1194
  via `emitStatus`. Reserved for FSMs with neither `onError` wiring nor
255
1195
  a pre-emption event.
@@ -266,27 +1206,76 @@ The runtime shall emit, at minimum:
266
1206
  default is to emit on every transition and let the host filter; hosts
267
1207
  may bind a stricter rule.
268
1208
  - One `emitTelemetry` per state transition under a namespaced topic
269
- (recommended `playbook.fsm.state`), with payload `{ from, to, event }`.
1209
+ (recommended `playbook.fsm.state`), with structured `from`, `to`, `event`,
1210
+ `previousState`, and `state` fields. Descriptors carry the JSON-safe XState
1211
+ value, active stable ids from public state metadata, tags, status, and
1212
+ quiescence; they do not inspect private XState nodes.
1213
+ The payload shall additionally carry the exact pending Boss question or
1214
+ keyed questions selected from public snapshot context and normalize any
1215
+ transition error without retaining a raw `Error` instance.
1216
+ Do not reduce this payload to the current state: `from` and `previousState`
1217
+ are the authoritative prior descriptor, while `to` and `state` are the new
1218
+ descriptor. On the first observed transition, use the initialized state as
1219
+ both the prior and new descriptor when no earlier transition exists.
1220
+ Snapshot and recursively freeze the complete described telemetry payload
1221
+ independently from the state retained as `previousState`, so an observer
1222
+ cannot mutate a later transition's authoritative `from` state.
270
1223
  Observers consume telemetry; the runtime never interprets the topic.
271
1224
 
272
- Player prompts and adjudicator JSON ride the host's own record channels when the host has them (cligent's `captain_*` / `player_*`).
273
- The runtime shall not duplicate them into `emitTelemetry`.
1225
+ Player prompts and adjudicator JSON may additionally ride the host's own record channels when the host has them (cligent's `captain_*` / `player_*`).
1226
+ The `playbook.trace` copies are the host-agnostic runtime-boundary record required by §Playbook trace.
274
1227
 
275
1228
  ## Output
276
1229
 
277
1230
  The link compiler emits **one** TypeScript module that:
278
1231
 
279
- - Imports the FSM artifact by relative path.
1232
+ - Imports the FSM artifact by relative path with an extension-bearing
1233
+ runtime specifier. When the linked TypeScript is part of a package that
1234
+ compiles and ships JavaScript siblings, the source shall use the
1235
+ NodeNext-compatible `.js` specifier (for example `./code.fsm.js`), never a
1236
+ `.ts` specifier that the package's supported Node versions cannot load.
1237
+ An explicitly source-only host may instead retain `.ts` only when that
1238
+ host supports direct TypeScript loading and no JavaScript build is shipped.
1239
+ - Restricts itself to erasable TypeScript syntax — type annotations
1240
+ that strip cleanly, no constructor parameter properties, `enum`s, or
1241
+ namespaces — so a host running under type stripping loads it
1242
+ directly.
280
1243
  - Imports XState's actor primitives (`createActor`, `fromPromise`,
281
1244
  `setup`'s `.provide`).
1245
+ - Imports `PQueue` from `p-queue` for its single serialized emission channel.
1246
+ - Imports the FSM's exported machine/actor input and output types and uses
1247
+ those exact types in `.provide(...)`; it shall not redeclare look-alike
1248
+ Captain, player, playbook, question, or output contracts beside the linked
1249
+ runtime.
1250
+ - Imports the applicable shared helpers from the extension-bearing
1251
+ `xstate-runtime.js` sibling of the resolved shared `--link` contract module,
1252
+ with that sibling path relativized from the emitted artifact exactly as the
1253
+ contract import is. Every runtime uses `assertJsonSafe`, `snapshotJsonValue`,
1254
+ `snapshotPlaybookSession`, `normalizeError`,
1255
+ `normalizePlaybookSnapshot`, and `waitForPlaybookQuiescence`; it additionally
1256
+ imports `combineAbortSignals`, result validators, and
1257
+ `createNestedPlaybookBridge` only when its actor and composition paths need
1258
+ them. It shall use those helpers instead of emitting weaker local JSON,
1259
+ error, snapshot, nested-call, or imperative-wait implementations.
282
1260
  - Exports `createPlaybookRuntime` and the typed `PlaybookRuntimeOptions`
283
1261
  interface for that playbook.
1262
+ - Exposes, under an `_internal` export, the pure helpers verification
1263
+ needs — at least the player-prompt and Captain-prompt composers
1264
+ (`composePlayerPrompt` and `composeCaptainPrompt`) — so
1265
+ compilation-correctness tests can exercise composition without a host.
284
1266
  - Holds no host-specific types and no host primitive calls. The runtime
285
- speaks only `PlaybookPorts`.
1267
+ speaks only `PlaybookPorts` for every agent and host concern; the sole
1268
+ exception is `node:child_process`, imported only when the FSM declares a
1269
+ `script` actor, so §Script execution can run its deterministic commands
1270
+ locally.
286
1271
  - Records the linker inputs (FSM path, player binding, strategies) in a
287
1272
  top-of-file header comment so the file is reproducible from the same
288
1273
  inputs.
289
- - Sources the contract types (`PlayerResult`, `PlaybookPorts`,
1274
+ - Sources the contract types (`PlayerResult`, `PlayerCallOptions`,
1275
+ `CaptainResult`, `CaptainCallOptions`, `PlaybookPorts`, `PlaybookSession`,
1276
+ `PlaybookTraceEvent`,
1277
+ `PlaybookCallRequest`, `PlaybookCallResult`, `PlaybookCallStart`,
1278
+ `PlaybookStateValue`, `PlaybookState`, `PlaybookRunResult`,
290
1279
  `PlaybookRuntime`, `PlaybookRuntimeFactory`) from a single shared
291
1280
  type-only module instead of redefining them, and re-exports the names
292
1281
  its consumers import, so every linked playbook shares one contract
@@ -294,6 +1283,25 @@ The link compiler emits **one** TypeScript module that:
294
1283
  dependency runs one way — from each linked module to the shared
295
1284
  contract, never the reverse.
296
1285
 
1286
+ When a co-located integration test for the linked runtime already exists, the
1287
+ link compiler shall run it before reporting success and treat any failure as a
1288
+ generation failure. It shall not delete, skip, or weaken that suite to make a
1289
+ new artifact pass; the suite is executable evidence for lifecycle, ordering,
1290
+ error-propagation, and host-boundary requirements that static artifact checks
1291
+ cannot establish.
1292
+
1293
+ Internal trace/status helpers may accept `unknown`, validate it with the same
1294
+ JSON-safety rules as the public boundary, and only then emit a `JsonValue`.
1295
+ They shall not require nominally typed public interfaces such as
1296
+ `NormalizedError`, `PlaybookState`, `PlaybookCallRequest`, or
1297
+ `PlaybookCallResult` to satisfy a `JsonValue` index signature at compile time,
1298
+ and they shall not silence that mismatch with an unchecked cast.
1299
+ Prompt placeholder substitution shall make one callback-based pass over the
1300
+ original template. Replacement strings are literal: placeholder-looking text
1301
+ inside Boss/catalog/plan/result values and JavaScript replacement tokens such
1302
+ as `$&`, `$$`, dollar-backtick, and `$'` shall not be interpreted or
1303
+ substituted again.
1304
+
297
1305
  ## Host adaptation (informative, not normative)
298
1306
 
299
1307
  A host integrates with playbooks via a small adapter that:
@@ -304,14 +1312,18 @@ A host integrates with playbooks via a small adapter that:
304
1312
  2. Imports the module and constructs the runtime with options forwarded
305
1313
  verbatim from the host config.
306
1314
  3. Implements `PlaybookPorts` by wrapping the host's own primitives —
307
- for cligent/tmux-play this is `callPlayer ← context.callPlayer`,
308
- `callJudge ← context.callCaptain`, `emitStatus`/`emitTelemetry` ←
309
- `session.emitStatus`/`session.emitTelemetry`.
310
- 4. Calls `runtime.init(ports)` once at session start, forwards each
311
- Boss turn to `runtime.handleBossInput`, and calls
1315
+ for cligent/tmux-play this is `callPlayer ← context.callPlayer`, visible
1316
+ `callCaptain ← context.callCaptain`, hidden
1317
+ `callJudge ← context.callCaptain`, nested `callPlaybook ←` the Captain
1318
+ session stack, and `emitStatus`/`emitTelemetry`
1319
+ `session.emitStatus`/`session.emitTelemetry`. The two Captain-backed ports
1320
+ share one abort-aware concurrency-one queue.
1321
+ 4. Generates a unique playbook-session id, calls
1322
+ `runtime.init({ sessionId, rootSessionId: sessionId, depth: 0,
1323
+ playbookId, ports })` once at session start, forwards each Boss turn to
1324
+ `runtime.handleBossInput`, and calls
312
1325
  `runtime.dispose()` at session end.
313
1326
 
314
- The adapter is ~30 lines regardless of which playbook is loaded.
315
1327
  Its location is a project-organization choice:
316
1328
 
317
1329
  - **Playbook repo** — simplest when the playbook author owns the integration; keeps host primitives a lower-layer dependency.
@@ -328,9 +1340,13 @@ This spec is silent on the choice; the contract is the same in any location.
328
1340
  layouts — where these live is a per-project decision (see
329
1341
  §Host adaptation); this spec only constrains the `PlaybookPorts`
330
1342
  contract they satisfy.
331
- - Persisting FSM context across sessions, multi-Boss orchestration, or
332
- visualizer rendering separate hosts/observers may add them without
333
- changing this spec.
1343
+ - Trace persistence, multiple Boss-selected root engagements, recursive
1344
+ playbook calls, multi-Boss orchestration, or visualizer rendering
1345
+ separate hosts/observers may add them without changing this spec. A host
1346
+ may persist the emitted trace, but the runtime does not rehydrate a
1347
+ disposed actor from it. Parked-session durability is in scope only
1348
+ through the optional snapshot surface of §Parked-session snapshot
1349
+ (DR-014); everything beyond it remains out of scope.
334
1350
 
335
1351
  New behavior in any of these areas requires a separate slc spec.
336
1352
 
@@ -339,3 +1355,4 @@ New behavior in any of these areas requires a separate slc spec.
339
1355
  [1]: [text2gears](text2gears.md) "First phase: text → GEARS spec items."
340
1356
  [2]: [gears2fsm](gears2fsm.md) "Second phase: GEARS items → FSM artifact."
341
1357
  [3]: https://stately.ai/docs/actors "XState actors — `createActor`, snapshots, abort signal handling."
1358
+ [4]: https://github.com/sindresorhus/p-queue#readme "p-queue concurrency and AbortSignal support."