@sublang/playbook 4.0.0 → 5.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.
package/slc/link.md CHANGED
@@ -143,7 +143,7 @@ exactly one `pendingCall` is active.
143
143
  Control-plane exceptions reject the runtime method rather than masquerade as a
144
144
  recoverable workflow `failed` result.
145
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.
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), strategy overrides the linker exposes, and — where the compiled playbook's policy needs a host seam — host-supplied port-shaped callbacks the linker exposes as option members whose types the artifact itself declares, so the six-member `PlaybookPorts` contract and the shared contract module stay free of host types.
147
147
  The link compiler emits a typed options interface per playbook based on the FSM's `CodingInput` (or equivalent).
148
148
  The CLI's absence of `--link-option` values does not mean that
149
149
  `PlaybookRuntimeOptions` is empty. CLI link options are compile-time inputs;
@@ -240,6 +240,10 @@ type PlaybookCallStart =
240
240
 
241
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)).
242
242
  The runtime treats `status !== 'ok'` as a player failure and routes it through the FSM's error path (§Abort).
243
+ An `ok` player result whose `finalText` is missing, empty, or whitespace-only
244
+ earns exactly one corrective re-ask: the same player call repeated under the
245
+ stored resume selection, traced as its own player-call pair, before a second
246
+ such result routes through the same error path.
243
247
 
244
248
  `callCaptain` runs a direct-Captain FSM actor against the host's Captain
245
249
  agent. The linked runtime shall pass
@@ -257,9 +261,13 @@ array requests a tool-free call, while omission preserves the host Captain's
257
261
  configured tools.
258
262
  `CaptainResult` carries no resume token or player-continuation selection.
259
263
  A non-`ok`
260
- result, or an `ok` result without non-empty `finalText`, shall record that
261
- failure on the call's single finish trace and reject the actor through the
262
- FSM's error path. These structured host-result failures are recoverable
264
+ result, or an `ok` result whose `finalText` is missing, empty, or
265
+ whitespace-only, shall record that failure on the call's single finish trace.
266
+ A non-`ok` result shall then reject the actor through the FSM's error path
267
+ with no corrective re-ask; an empty `ok` result shall first earn exactly one
268
+ corrective re-ask — the same call repeated, traced as its own
269
+ started/finished pair — and only a second such result shall reject the actor
270
+ the same way. These structured host-result failures are recoverable
263
271
  workflow failures, not control-plane failures: the runtime shall let the actor
264
272
  take `onError`, drive it to quiescence, drain ordered emissions, and resolve
265
273
  the public method with `{ outcome: 'failed' }` carrying the failure state's
@@ -347,6 +355,8 @@ type PlaybookTraceType =
347
355
  | 'captain.call.finished'
348
356
  | 'playbook.call.started'
349
357
  | 'playbook.call.finished'
358
+ | 'apply.started'
359
+ | 'apply.finished'
350
360
  | 'fsm.transition'
351
361
  | 'status.emitted'
352
362
  | 'boss.input.settled'
@@ -373,10 +383,19 @@ The trace types are `session.started`, `boss.input.received`,
373
383
  `judge.call.started`, `judge.call.finished`, `player.call.started`,
374
384
  `player.call.finished`, `captain.call.started`, `captain.call.finished`,
375
385
  `playbook.call.started`,
376
- `playbook.call.finished`, `fsm.transition`, `status.emitted`,
386
+ `playbook.call.finished`, `apply.started`, `apply.finished`,
387
+ `fsm.transition`, `status.emitted`,
377
388
  `boss.input.settled`, and `session.disposed`.
378
389
  Call pairs carry exact prompts and replies, normalized failures, actor and state
379
390
  identity, and their boundary-specific options.
391
+ `apply.started` and `apply.finished` are the paired schema-2 boundary of an
392
+ executed `apply()` call on a runtime implementing the optional control surface
393
+ (§Control surface): both carry the action id and idempotency `key` (plus the
394
+ singular `stateId` on start when one exists), the pair shares one
395
+ session-unique `apply-<n>` call id and the boundary's turn id, and the finish
396
+ adds the receipt `disposition` with its `reason`, normalized `error`, or
397
+ projected `run` result, all JSON-safe. A repeated idempotency key returns the
398
+ recorded receipt without a new pair.
380
399
  Direct-Captain start and finish payloads shall carry `allowedTools` exactly when
381
400
  the originating `CaptainCallOptions` selects it and shall omit the member when
382
401
  the call preserves the host Captain's configured tools.
@@ -392,16 +411,23 @@ its message and optional data; consumers shall not have to recover state
392
411
  identity from a nested ad hoc object.
393
412
  Judge results use `reply`; player start and finish payloads both carry the
394
413
  selected `resume`; Captain start and finish payloads both carry
395
- the exact composed prompt, `visibility: 'visible'`, the direct invocation's
396
- `stateId` and `sourceItem`, and no player resume selection or resume token;
414
+ the exact composed prompt, the boundary's selected `visibility`, the direct
415
+ invocation's `stateId` and `sourceItem`, and no player resume selection or
416
+ resume token — a visible workflow call carries its runtime-owned
417
+ `resume: false` selection, while a hidden controller call
418
+ (§Captain adjudication) omits the `resume` member altogether, its
419
+ durable-conversation selection being host-owned;
397
420
  judge `purpose` is
398
421
  `boss-input-classification`, `player-output-adjudication`, or
399
422
  `captain-output-adjudication`; and every error uses
400
423
  `{ name, message, stack? }` rather than a raw string or `Error` instance.
401
424
  The Captain finish payload shall preserve the exact `CaptainResult` status and
402
425
  final text when present, while carrying any failure in normalized form.
403
- An `ok` result without `finalText` therefore retains status `ok` but also
404
- carries the normalized missing-text failure that makes the actor reject.
426
+ An `ok` result without non-empty `finalText` therefore retains status `ok` but
427
+ also carries the normalized missing-text failure; the corrective re-ask that
428
+ follows (§PlaybookPorts contract) traces as its own started/finished pair, and,
429
+ when that corrective call happens, only a second such finish makes the actor
430
+ reject.
405
431
  If the Captain port rejects before returning a result, the finish instead
406
432
  carries explicit `status: 'aborted'` when the combined signal has aborted or
407
433
  `status: 'error'` otherwise. A finish boundary never omits status merely
@@ -596,8 +622,17 @@ Where the current ready or reconstructed terminal machine accepts exactly one
596
622
  ordinary textual entry event and no Boss question is pending, the runtime
597
623
  shall send that event deterministically and attach the exact original text to
598
624
  its declared textual payload field without invoking `callJudge`.
599
- The default Captain's ready entry is
600
- `{ type: 'BOSS_INTENT', bossIntent: turn.text }`.
625
+ The default Captain the controller playbook of
626
+ [gears2fsm "Setup"](gears2fsm.md#setup) is deterministic at every parked
627
+ entry: the runtime maps each Boss turn from the exact text and the host's
628
+ deterministic command-parse resolution, supplied through the linked options'
629
+ controller port, to the rewritten machine's hub entry union —
630
+ `{ type: 'BOSS_TURN', bossText: turn.text }` for an undecided turn,
631
+ `{ type: 'PARSED_RESPOND', bossText: turn.text }` for a parse-resolved
632
+ `respond`, `{ type: 'PARSED_ACTION', bossText: turn.text, decision }` carrying
633
+ the injected parse-resolved decision object, and `{ type: 'SHUTDOWN' }` for the
634
+ host's teardown resolution — and invokes no classifier judge call; the exact
635
+ original text still rides only the runtime-owned textual payload field.
601
636
  All other non-empty turns shall use `callJudge` only to choose one of the FSM's
602
637
  event kinds and non-text routing fields, or no FSM action.
603
638
  The classifier prompt shall include the exact, unmodified `turn.text` in a
@@ -677,7 +712,7 @@ If a host forwards text beginning with `/` to `handleBossInput`, the runtime tre
677
712
  Hosts that receive structured control input shall resolve host-level concerns before choosing a playbook runtime.
678
713
  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.
679
714
 
680
- `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.
715
+ Within `handleBossInput` classification, `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; `apply()` of a runtime-advertised action (§Control surface) is the second, runtime-validated path to the same events, and on neither path does the host fabricate an FSM event itself.
681
716
  It is _not_ an abort surface; aborts go through the abort signal and the strategies in §Abort.
682
717
  Hosts where the abort signal is terminal (e.g., SIGINT runs shutdown) shall not route abort to `BOSS_INTERRUPT`.
683
718
 
@@ -746,6 +781,55 @@ presentation. The linked
746
781
  runtime shall not make a second visible Captain call or expose the hidden
747
782
  structured adjudication merely to present the same response.
748
783
 
784
+ That visible-call presentation — visible Captain prose as the Boss
785
+ presentation, a separate hidden adjudicator that never authors the
786
+ `question`/`response` fields, and runtime injection of the visible
787
+ `finalText` — stays scoped to visible-presentation playbooks such as CODE
788
+ and DISCUSS. For a controller playbook, whose FSM declares the controller
789
+ decision-state class of [gears2fsm "Setup"](gears2fsm.md#setup), the
790
+ Captain-call presentation admits the hidden controller form instead
791
+ (DR-029): the decision and closing-reply Captain calls run
792
+ `{ visibility: 'hidden' }` on the host's durable conversation, whose resume
793
+ token the host pins and rotates (DR-029), and the decision call's reply
794
+ is the `{ action, … }` control JSON itself — validated by the linked runtime
795
+ against the declared decision-state contract rather than adjudicated through
796
+ a separate judge call, with exactly one corrective re-ask appending the
797
+ rejection reason and the restated reply contract (the DR-025 corrective
798
+ pattern). Because the host owns that conversation, the `resume` member the
799
+ runtime is required to pass on a controller call carries no continuity
800
+ meaning: the runtime passes `resume: false` because it holds no token, and
801
+ the host's pinned durable selection overrides it — a controller call shall
802
+ never be read as a request for a fresh conversation.
803
+
804
+ The validated selection is not itself an effect. The linked runtime shall
805
+ submit it through the host-supplied controller port the linker exposed as an
806
+ option member (§PlaybookRuntime contract) and shall take the returned
807
+ settlement as the only evidence of what happened. That settlement, carried
808
+ beside the selected action as the guard discriminant, is the decision
809
+ invocation's own result — the actor output the decision state's `onDone` arms
810
+ select on and its evidence action records — so the effect reports through the
811
+ same invocation that decided it, with no second boundary and no host-sent
812
+ event. A controller prose state (a parse-resolved `respond` reply, an acting
813
+ turn's closing reply) settles on its declared single outcome and returns no
814
+ prose to the machine at all.
815
+
816
+ That reply is control data, never Boss presentation: the runtime
817
+ shall not inject the `question` or `response` presentation fields into a
818
+ controller result — no visible Captain call exists to own them. Controller
819
+ prose reaches the Boss only as host-validated captain speech surfaced
820
+ through the host's presentation seam, cligent `CaptainContext.emitReply`
821
+ (DR-029). Because no prose returns to the machine and no presentation
822
+ field is injected, the host's own `callCaptain` implementation is that seam:
823
+ it already holds the `CaptainResult` of the call it just served, and it
824
+ identifies which call that is from the paired `captain.call.started` boundary
825
+ the runtime emits before invoking the port, whose identity carries the
826
+ invoking `stateId` and `sourceItem` (§Playbook trace). No prose therefore
827
+ needs a return path through the machine, and none exists.
828
+ The visible-call `question`/`response` injection rule above and
829
+ the `{ visibility: 'visible', resume: false }` workflow-call selection
830
+ (§PlaybookPorts contract, §Captain prompt composition) stay the
831
+ visible-presentation shape for non-controller playbooks.
832
+
749
833
  The adjudicator shall fail loudly on:
750
834
 
751
835
  - A guard the state does not declare,
@@ -791,9 +875,9 @@ independent cleanup evidence.
791
875
  ## Script execution
792
876
 
793
877
  Where the FSM declares the typed `script` actor from
794
- [gears2fsm "Setup"](gears2fsm.md#setup), the linked runtime shall provide
795
- its implementation through the shared factory (§Output); the linker shall
796
- not regenerate a script executor inside each emitted module.
878
+ [gears2fsm "Setup"](gears2fsm.md#setup), a factory-backed linked runtime shall
879
+ provide its implementation through the shared factory (§Output); the linker
880
+ shall not regenerate a script executor inside a factory-backed emitted module.
797
881
  A script invocation is the one actor kind that runs without any agent:
798
882
  it makes no `callPlayer`, `callCaptain`, or `callJudge` call and needs no
799
883
  adjudication.
@@ -1170,6 +1254,162 @@ not `active` or whose state descriptor cannot be normalized shall fail
1170
1254
  A restore failure shall leave the runtime unbound so `dispose` remains
1171
1255
  callable and terminal.
1172
1256
 
1257
+ ## Control surface (optional)
1258
+
1259
+ A linked runtime may implement the optional control-surface capability of
1260
+ `@sublang/playbook/runtime` — `describe()` and `apply(...)` — so a host can
1261
+ observe the parked machine and execute a runtime-advertised recovery or jump
1262
+ action without fabricating an FSM event (DR-029). A runtime that
1263
+ implements either member shall implement both; every runtime the shared
1264
+ `createXStatePlaybookRuntime` factory constructs implements the pair. A
1265
+ runtime lacking the pair advertises no actions, and plain text delivery is
1266
+ the only verb against it. Presence is feature-detected like the
1267
+ parked-session snapshot capability; the pair changes no runtime ABI and no
1268
+ artifact or snapshot schema.
1269
+
1270
+ ```typescript
1271
+ interface PlaybookControlAction {
1272
+ id: string; // stable within the returned view
1273
+ label: string; // runtime-written, Boss-appropriate
1274
+ }
1275
+
1276
+ interface PlaybookControlView {
1277
+ state: PlaybookState;
1278
+ stateDescription?: string; // runtime-published meaning of the current state
1279
+ context?: JsonValue; // the runtime's authored projection, sanitized
1280
+ pendingQuestions: readonly PlaybookPendingBossQuestion[];
1281
+ lastError?: NormalizedError;
1282
+ actions: readonly PlaybookControlAction[];
1283
+ }
1284
+
1285
+ type PlaybookControlReceipt =
1286
+ | { disposition: 'rejected'; reason: string } // before any effect
1287
+ | { disposition: 'executed'; run: PlaybookRunResult }
1288
+ | { disposition: 'failed'; error: NormalizedError }; // effects may exist
1289
+
1290
+ // Optional PlaybookRuntime members — both or neither:
1291
+ describe?(): PlaybookControlView;
1292
+ apply?(input: { actionId: string; key: string; signal: AbortSignal }): Promise<PlaybookControlReceipt>;
1293
+ ```
1294
+
1295
+ `describe()` shall be side-effect free — it emits no trace, status, or
1296
+ telemetry and moves no machine state — and is valid at parked quiescence
1297
+ outside an active `handleBossInput`/`resumePlaybookCall`/`apply` boundary;
1298
+ during an active boundary, before `init`, or after disposal begins it shall
1299
+ throw. The view carries the current normalized state descriptor, the state
1300
+ description defined below, the authored context projection defined below, the
1301
+ pending Boss questions with their stable ids, the last recorded error in
1302
+ normalized form, and the currently valid actions.
1303
+
1304
+ `stateDescription` is the runtime's own Boss-facing statement of what its
1305
+ current state means, taken from the same source state descriptions the action
1306
+ labels below are written from. A controller host has no other grounding for a
1307
+ status answer, and an internal state id is not text a reply may repeat, so the
1308
+ runtime publishes the meaning rather than leaving the host to substitute the
1309
+ identifier for it. A state whose source declares no description carries no
1310
+ `stateDescription`: an id is never promoted into a description, so a host is
1311
+ never handed an identifier dressed as meaning.
1312
+
1313
+ The view's `context` is an explicit projection the linked runtime **authors**,
1314
+ never an allow-by-default serialization of the FSM context (PBRT-52).
1315
+ Only the runtime knows which of its context members are safe and relevant
1316
+ for a controller prompt, while the host receiving the view cannot inspect an
1317
+ opaque blob for the player rosters, option values, and raw player output its
1318
+ own prompts must exclude; exporting by default makes the two obligations
1319
+ unsatisfiable together and gives every member added to an FSM later the wrong
1320
+ default. The rules:
1321
+
1322
+ - The emitted module declares the projection in its `spec` as
1323
+ `controlContextFields` — the FSM context member names its view exposes, in
1324
+ the order it names them — and the factory exports those and nothing else.
1325
+ - A runtime naming no member carries no `context` at all, so a member is
1326
+ private until an artifact names it and extending an FSM leaks nothing by
1327
+ omission.
1328
+ - Sanitization sits on top of the projection, not in place of it: a named
1329
+ member is still normalized (raw `Error` values normalized) and dropped when
1330
+ it cannot be made JSON-safe, rather than thrown, since `describe` stays
1331
+ side-effect free and total.
1332
+ - The two members the view surfaces first-class — the pending Boss question
1333
+ and the last error — cannot be named. A projection naming either is a
1334
+ construction error, failing runtime construction rather than being silently
1335
+ ignored.
1336
+ - The host still composes its own prompt block from the projection rather than
1337
+ pasting the projection in, so no runtime's exported value can forge a block
1338
+ into an envelope the host owns.
1339
+
1340
+ Actions derive from the live snapshot, only at the same safe point the
1341
+ parked-session snapshot uses (actor status `active`, quiescent, no pending
1342
+ nested call); anywhere else `actions` is empty while the rest of the view
1343
+ still describes the state. Two families exist, labeled from source state
1344
+ descriptions:
1345
+
1346
+ - **Failure-state retry** — while the singular state id is the recoverable
1347
+ failure state and the runtime holds a recorded last classified event (the
1348
+ event a public Boss boundary sent that drove the run into `failed`, kept
1349
+ with its recorded payload), and the live snapshot accepts that event, the
1350
+ runtime shall advertise `retry:<EVENT_TYPE>` replaying exactly that
1351
+ recorded event.
1352
+ - **Jump entries** — for each registered resumable state id whose
1353
+ explicit-state-jump event (`BOSS_INTERRUPT` with that `targetId`, optional
1354
+ textual fields omitted) the live snapshot accepts, guards included, the
1355
+ runtime shall advertise `jump:<stateId>`.
1356
+
1357
+ A candidate whose event requires a payload the runtime cannot source from
1358
+ recorded state shall be excluded from `actions` — `apply` never invents free
1359
+ text and never enters Boss-input classification. A candidate whose *label*
1360
+ could only be an identifier is excluded on the same terms: a label never falls
1361
+ back to a target id or to the replayed event type, because a controller host
1362
+ names an executed or refused action by its label and never by its id, so an
1363
+ identifier used as a label defeats that substitution. A jump whose target
1364
+ publishes no description is therefore not advertised — borrowing another
1365
+ state's description would name the wrong state — and a retry falls back from
1366
+ its target's description to its own source state's, and is not advertised when
1367
+ neither exists.
1368
+
1369
+ `apply({ actionId, key, signal })` shall revalidate the action against the
1370
+ live state and settle `{ disposition: 'rejected', reason }` with no effect
1371
+ when it is no longer advertised. It shall execute an accepted action at most
1372
+ once per idempotency `key`: the receipt is recorded at acceptance, before
1373
+ the settlement emissions, and a repeated key returns the recorded receipt
1374
+ verbatim with no revalidation, no execution, and no new trace pair within the
1375
+ runtime instance.
1376
+ Only accepted receipts (`executed` or `failed`) are recorded and final for
1377
+ their key. A rejection settles before acceptance and records nothing under
1378
+ its key — a later call with that key revalidates afresh, traces its own
1379
+ pair, and may execute once the action is advertised — and a key whose call
1380
+ threw before reaching acceptance (lifecycle misuse, invalid input, a
1381
+ pre-acceptance abort, a rejected start-boundary sink) likewise records
1382
+ nothing, so a later call with that key may execute. Executing sends the
1383
+ validated event through the same actor drive as `handleBossInput` — state
1384
+ transitions, player/judge boundaries, statuses, and traces flow unchanged —
1385
+ and settles `executed` with the projected run result, or `failed` with the
1386
+ normalized error when the run settles in the failure state, aborts, or a
1387
+ post-acceptance control-plane error lands (effects may exist). `signal`
1388
+ follows §Abort exactly as a Boss-turn signal does; an abort after acceptance
1389
+ settles the `failed` receipt rather than rejecting. The boundary traces as
1390
+ the paired `apply.started` / `apply.finished` events of §Playbook trace, and
1391
+ `apply` shares the single active-boundary sentinel with `handleBossInput`
1392
+ and `resumePlaybookCall`.
1393
+
1394
+ Acceptance is also the line past which `apply` does not throw, and
1395
+ publication — the `apply.finished` emission — is the line past which its
1396
+ receipt no longer changes. A settlement failure after acceptance but before
1397
+ publication (a rejecting emission drain) settles the `failed` receipt carrying
1398
+ its normalized error, replacing the one recorded at acceptance so the finish
1399
+ trace, the returned receipt, and any replay of the key report one settlement.
1400
+ A settlement failure at or after publication (a rejecting `apply.finished`
1401
+ sink) does not: the disposition is already emitted, so no rewrite can make the
1402
+ trace and the return agree, and a receipt states what happened to the effect
1403
+ rather than what happened to its telemetry. The published receipt stands, is
1404
+ returned and replayed verbatim, and the delivery failure travels on the
1405
+ runtime's emission-failure channel to surface from the next public boundary
1406
+ that drains.
1407
+
1408
+ The recorded receipts and the recorded last classified event are
1409
+ process-local: the schema-1 parked-session snapshot persists neither, and a
1410
+ restored runtime advertises a retry again only after its next classified
1411
+ event.
1412
+
1173
1413
  ## Abort
1174
1414
 
1175
1415
  `handleBossInput.signal` is the abort surface.
@@ -1245,19 +1485,25 @@ The `playbook.trace` copies are the host-agnostic runtime-boundary record requir
1245
1485
 
1246
1486
  ## Output
1247
1487
 
1248
- The link compiler emits **one thin** TypeScript module per playbook.
1488
+ The link compiler emits one TypeScript module per playbook.
1489
+ For an FSM that declares no `type: 'parallel'` state, it shall emit the thin
1490
+ shared-factory module defined below.
1491
+ For an FSM that declares a parallel state, it shall emit bespoke linked
1492
+ machinery satisfying this document's runtime contract and shall not invoke
1493
+ `createXStatePlaybookRuntime`, whose supported domain is single-region FSMs
1494
+ under [DR-019](../specs/decisions/019-shared-linked-runtime-factory.md).
1249
1495
  The FSM-interpreter machinery — actor wiring, boundary tracing, Boss-event
1250
1496
  mapping, adjudication, script execution, nested-playbook bridging, session
1251
1497
  lifecycle, abort handling, and the optional parked-session snapshot
1252
- capability — is not regenerated per artifact: it ships once as the shared
1253
- `createXStatePlaybookRuntime(machine, spec)` factory exported by
1498
+ capability — is not regenerated for a factory-backed artifact: it ships once
1499
+ as the shared `createXStatePlaybookRuntime(machine, spec)` factory exported by
1254
1500
  `@sublang/playbook/xstate-runtime`, and the emitted module hands its FSM and
1255
1501
  a small per-playbook `spec` to that factory. Every behavioral section of
1256
1502
  this definition still binds the emitted module's runtime; the shared factory
1257
1503
  is how the emitted module satisfies them, so a runtime fix ships as a
1258
1504
  package release instead of a re-link of every artifact.
1259
1505
 
1260
- The emitted module:
1506
+ The thin emitted module:
1261
1507
 
1262
1508
  - Imports the FSM artifact by relative path with an extension-bearing
1263
1509
  runtime specifier. When the linked TypeScript is part of a package that
@@ -1297,10 +1543,23 @@ The emitted module:
1297
1543
  exceptions not covered by the canonical kebab-token-to-camel-field mapping
1298
1544
  and the canonical `<#>` → `irNumber` special case; the
1299
1545
  transition-event payload fields the FSM's Boss union declares; a
1300
- non-default player binding where the linker inputs supplied one; and any
1546
+ non-default player binding where the linker inputs supplied one; the
1547
+ `controlContextFields` projection of §Control surface; and any
1301
1548
  per-playbook strategy override (classifier, prompt composers,
1302
1549
  required-field extraction, status formatting) an earlier section of this
1303
- definition requires for that playbook. The metadata shall keep the shared
1550
+ definition requires for that playbook.
1551
+ `controlContextFields` is authored, not derived: the linker names the FSM
1552
+ context members the playbook's controller view exposes and no others, in the
1553
+ order the view should render them, omitting the member entirely where the
1554
+ playbook exposes no context. It is the one spec member whose default is
1555
+ *nothing* rather than everything — the factory exports no context for a
1556
+ module that supplies none — so a module emitted without it advertises a
1557
+ playbook with no Boss-visible context rather than one whose whole FSM
1558
+ context is Boss-visible. The linker shall not name a member the view
1559
+ surfaces first-class (the pending Boss question, the last error), which is a
1560
+ construction error, and shall not name a member carrying a resolved player
1561
+ roster, an option value, or player-authored text, which a controller host's
1562
+ prompts are required to exclude or to fence. The metadata shall keep the shared
1304
1563
  classifier's reply contract exactly flat `{ type, ...declaredFields }` and
1305
1564
  distinguish judge-authored routing fields from exact-text fields the
1306
1565
  runtime attaches itself. Everything else — player/script/captain/nested actor
@@ -1352,10 +1611,16 @@ The emitted module:
1352
1611
  - Default-exports the factory call as `createPlaybookRuntime`, typed
1353
1612
  `PlaybookRuntimeFactory<PlaybookRuntimeOptions>`.
1354
1613
  - Exposes, under an `_internal` export, the pure helpers verification
1355
- needs — at least the player-prompt and Captain-prompt composers
1356
- (`composePlayerPrompt` and `composeCaptainPrompt`), which may re-export
1357
- the shared defaults when the spec does not override composition — so
1358
- compilation-correctness tests can exercise composition without a host.
1614
+ needs — at least the prompt composers its own machine uses, which may
1615
+ re-export the shared defaults when the spec does not override composition —
1616
+ so compilation-correctness tests can exercise composition without a host.
1617
+ A playbook that calls players exposes `composePlayerPrompt`; a playbook
1618
+ whose states make direct-Captain calls exposes `composeCaptainPrompt`. A
1619
+ controller playbook that calls no players exposes no player composer:
1620
+ there is no composition to verify, and a stub under that name would
1621
+ describe work the module cannot do. `_internal` is not a public API — the
1622
+ leading underscore says so — and nothing in it is semver-stable; a helper
1623
+ a host is meant to call is a top-level export and is governed as one.
1359
1624
  - Holds no host-specific types and no host primitive calls. The runtime
1360
1625
  speaks only `PlaybookPorts` for every agent and host concern; the
1361
1626
  `node:child_process` dependency of §Script execution lives in the shared
@@ -1375,6 +1640,9 @@ The emitted module:
1375
1640
  dependency runs one way — from each linked module to the shared
1376
1641
  engine and contract, never the reverse.
1377
1642
 
1643
+ Both output profiles remain subject to the behavioral sections above and the
1644
+ verification requirements below.
1645
+
1378
1646
  When a co-located integration test for the linked runtime already exists, the
1379
1647
  link compiler shall run it before reporting success and treat any failure as a
1380
1648
  generation failure. It shall not delete, skip, or weaken that suite to make a
package/src/runtime.d.ts CHANGED
@@ -108,7 +108,7 @@ export interface PlaybookSession {
108
108
  depth: number;
109
109
  ports: PlaybookPorts;
110
110
  }
111
- export type PlaybookTraceType = 'session.started' | 'boss.input.received' | 'judge.call.started' | 'judge.call.finished' | 'player.call.started' | 'player.call.finished' | 'captain.call.started' | 'captain.call.finished' | 'playbook.call.started' | 'playbook.call.finished' | 'fsm.transition' | 'status.emitted' | 'boss.input.settled' | 'session.disposed';
111
+ export type PlaybookTraceType = 'session.started' | 'boss.input.received' | 'judge.call.started' | 'judge.call.finished' | 'player.call.started' | 'player.call.finished' | 'captain.call.started' | 'captain.call.finished' | 'playbook.call.started' | 'playbook.call.finished' | 'apply.started' | 'apply.finished' | 'fsm.transition' | 'status.emitted' | 'boss.input.settled' | 'session.disposed';
112
112
  export interface PlaybookTraceEvent {
113
113
  schemaVersion: 2;
114
114
  sessionId: string;
@@ -148,10 +148,38 @@ export interface PlaybookRuntimeSnapshot {
148
148
  state: PlaybookState;
149
149
  pendingBossQuestions: readonly PlaybookPendingBossQuestion[];
150
150
  }
151
+ export interface PlaybookControlAction {
152
+ id: string;
153
+ label: string;
154
+ }
155
+ export interface PlaybookControlView {
156
+ state: PlaybookState;
157
+ stateDescription?: string;
158
+ context?: JsonValue;
159
+ pendingQuestions: readonly PlaybookPendingBossQuestion[];
160
+ lastError?: NormalizedError;
161
+ actions: readonly PlaybookControlAction[];
162
+ }
163
+ export type PlaybookControlReceipt = {
164
+ disposition: 'rejected';
165
+ reason: string;
166
+ } | {
167
+ disposition: 'executed';
168
+ run: PlaybookRunResult;
169
+ } | {
170
+ disposition: 'failed';
171
+ error: NormalizedError;
172
+ };
151
173
  export interface PlaybookRuntime {
152
174
  init(session: PlaybookSession): Promise<void>;
153
175
  exportSnapshot?(): PlaybookRuntimeSnapshot | undefined;
154
176
  restore?(session: PlaybookSession, snapshot: PlaybookRuntimeSnapshot): Promise<void>;
177
+ describe?(): PlaybookControlView;
178
+ apply?(input: {
179
+ actionId: string;
180
+ key: string;
181
+ signal: AbortSignal;
182
+ }): Promise<PlaybookControlReceipt>;
155
183
  handleBossInput(turn: {
156
184
  text: string;
157
185
  signal: AbortSignal;
package/src/runtime.ts CHANGED
@@ -157,6 +157,8 @@ export type PlaybookTraceType =
157
157
  | 'captain.call.finished'
158
158
  | 'playbook.call.started'
159
159
  | 'playbook.call.finished'
160
+ | 'apply.started'
161
+ | 'apply.finished'
160
162
  | 'fsm.transition'
161
163
  | 'status.emitted'
162
164
  | 'boss.input.settled'
@@ -206,6 +208,38 @@ export interface PlaybookRuntimeSnapshot {
206
208
  pendingBossQuestions: readonly PlaybookPendingBossQuestion[];
207
209
  }
208
210
 
211
+ // DR-029: one currently valid, runtime-advertised control action. The id
212
+ // is stable within the returned view; the label is runtime-written,
213
+ // Boss-appropriate text derived from source state descriptions.
214
+ export interface PlaybookControlAction {
215
+ id: string;
216
+ label: string;
217
+ }
218
+
219
+ // DR-029: the sanitized control view `describe()` returns — current
220
+ // state and the runtime-written description of what that state means,
221
+ // the authored context projection, pending Boss questions, the last
222
+ // recorded error, and the currently valid actions. `stateDescription` is
223
+ // the Boss-appropriate grounding a host may speak from; the state id is
224
+ // internal and is absent from it whenever the runtime's source declares
225
+ // no description for the state it is in.
226
+ export interface PlaybookControlView {
227
+ state: PlaybookState;
228
+ stateDescription?: string;
229
+ context?: JsonValue;
230
+ pendingQuestions: readonly PlaybookPendingBossQuestion[];
231
+ lastError?: NormalizedError;
232
+ actions: readonly PlaybookControlAction[];
233
+ }
234
+
235
+ // DR-029: the receipt `apply()` returns says which of three things
236
+ // happened — rejected before any effect, executed with the settled run
237
+ // result, or failed after effects may exist.
238
+ export type PlaybookControlReceipt =
239
+ | { disposition: 'rejected'; reason: string }
240
+ | { disposition: 'executed'; run: PlaybookRunResult }
241
+ | { disposition: 'failed'; error: NormalizedError };
242
+
209
243
  export interface PlaybookRuntime {
210
244
  init(session: PlaybookSession): Promise<void>;
211
245
  // DR-014 §1 optional durable-session capability: a runtime implements
@@ -218,6 +252,19 @@ export interface PlaybookRuntime {
218
252
  session: PlaybookSession,
219
253
  snapshot: PlaybookRuntimeSnapshot,
220
254
  ): Promise<void>;
255
+ // DR-029 optional control-surface capability: a runtime implements
256
+ // both members or neither. `describe` is side-effect free and valid at
257
+ // parked quiescence outside an active boundary; `apply` revalidates the
258
+ // named action against the live state, executes it at most once per
259
+ // idempotency key within that runtime instance, and returns a receipt. A
260
+ // runtime lacking the pair advertises no actions; plain text delivery is
261
+ // the only verb against it.
262
+ describe?(): PlaybookControlView;
263
+ apply?(input: {
264
+ actionId: string;
265
+ key: string;
266
+ signal: AbortSignal;
267
+ }): Promise<PlaybookControlReceipt>;
221
268
  handleBossInput(turn: {
222
269
  text: string;
223
270
  signal: AbortSignal;