@sublang/playbook 17.8.0 → 17.9.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.
@@ -569,6 +569,9 @@ function pendingBossQuestionRecord(pending) {
569
569
  // Generic strategy defaults.
570
570
  // ---------------------------------------------------------------------------
571
571
  const CONTINUATION_PREAMBLE = 'Continue the same task using Boss’s reply below.';
572
+ // The labelled continuation a compiled artifact's composer renders, which
573
+ // slc's prompt-contract verifier pins. The runtime no longer sends it: it
574
+ // hands a composer no question and reply, and marks them itself (DR-090 §2).
572
575
  function continuationBlocks(input, resuming = false) {
573
576
  if (input.pendingBossQuestion === undefined || input.bossReply === undefined) {
574
577
  return [];
@@ -579,6 +582,59 @@ function continuationBlocks(input, resuming = false) {
579
582
  `Boss reply:\n${input.bossReply}`,
580
583
  ];
581
584
  }
585
+ // DR-090 §2: a Boss exchange reaches a player as structure, not as an
586
+ // English instruction — a marker line before each question and each reply,
587
+ // whatever language the run speaks.
588
+ const BOSS_QUESTION_MARKER = '? ⟶';
589
+ const BOSS_REPLY_MARKER = 'Boss ⟶';
590
+ function bossQuestionBlock(question) {
591
+ return `${BOSS_QUESTION_MARKER}\n${question}`;
592
+ }
593
+ function bossReplyBlock(reply) {
594
+ return `${BOSS_REPLY_MARKER}\n${reply}`;
595
+ }
596
+ /** DR-090 §2: the marked continuation of a call after a Boss reply. */
597
+ function markedContinuationBlocks(input, resuming = false) {
598
+ if (input.pendingBossQuestion === undefined || input.bossReply === undefined) {
599
+ return [];
600
+ }
601
+ return [
602
+ ...(resuming ? [] : [bossQuestionBlock(input.pendingBossQuestion.question)]),
603
+ bossReplyBlock(input.bossReply),
604
+ ];
605
+ }
606
+ /**
607
+ * The input a composer receives: without the question and reply, which the
608
+ * runtime marks itself (DR-090 §2), so a composer adds no labelled blocks.
609
+ */
610
+ function withoutContinuation(input) {
611
+ if (input.pendingBossQuestion === undefined && input.bossReply === undefined) {
612
+ return input;
613
+ }
614
+ const { pendingBossQuestion: _question, bossReply: _reply, ...rest } = input;
615
+ return rest;
616
+ }
617
+ /**
618
+ * DR-090 §1: the run's Boss exchanges a player call carries before its body,
619
+ * leaving out the one exchange the call's own continuation blocks carry, so
620
+ * the asking role reads its answered question once.
621
+ */
622
+ function bossExchangeBlocks(exchanges, input) {
623
+ let carried = -1;
624
+ if (input.pendingBossQuestion !== undefined && input.bossReply !== undefined) {
625
+ const question = input.pendingBossQuestion.question;
626
+ for (let index = exchanges.length - 1; index >= 0; index -= 1) {
627
+ const exchange = exchanges[index];
628
+ if (exchange.question === question && exchange.reply === input.bossReply) {
629
+ carried = index;
630
+ break;
631
+ }
632
+ }
633
+ }
634
+ return exchanges.flatMap((exchange, index) => index === carried
635
+ ? []
636
+ : [bossQuestionBlock(exchange.question), bossReplyBlock(exchange.reply)]);
637
+ }
582
638
  /** Add clarification context without repeating the question in a live conversation. */
583
639
  export function composePlayerContinuation(input, body, resuming = false) {
584
640
  return [...continuationBlocks(input, resuming), body].join('\n\n');
@@ -2005,8 +2061,9 @@ function snapshotOutcomeAuthority(descriptor, label, playerStates, verbatimPaylo
2005
2061
  // the machine's transition selection — guards only, no action runs — with
2006
2062
  // sample values, and the first combination of shapes whose arm carries that
2007
2063
  // outcome's accepted-outcome marker is the contract the judge is shown and the
2008
- // reconciler enforces. A field whose shape the machine does not reveal, or
2009
- // that it admits as a string, stays a string.
2064
+ // reconciler enforces. A field the machine admits as a string is recorded as
2065
+ // one; a field whose shape it does not reveal is recorded with none, so the
2066
+ // reconciler leaves its value to the machine's own validator (DR-091).
2010
2067
  // ---------------------------------------------------------------------------
2011
2068
  const SEMANTIC_SHAPE_SAMPLES = [
2012
2069
  ['string', 'sample'],
@@ -2045,9 +2102,10 @@ function shapeCombinations(fieldCount) {
2045
2102
  left.join(',').localeCompare(right.join(',')));
2046
2103
  }
2047
2104
  /**
2048
- * DR-089: the non-string JSON value shape the compiled machine admits for
2049
- * each semantic-owned field of each governed outcome, keyed by state id and
2050
- * then by outcome. Exported so linked machinery of an artifact's own reads
2105
+ * DR-089: the JSON value shape the compiled machine admits for each
2106
+ * semantic-owned field of each governed outcome, keyed by state id and then
2107
+ * by outcome. A field, outcome, or state the probe reveals no shape for is
2108
+ * left out (DR-091). Exported so linked machinery of an artifact's own reads
2051
2109
  * the same contract the shared factory enforces.
2052
2110
  */
2053
2111
  export function inferSemanticValueShapes(machine, authority) {
@@ -2127,18 +2185,17 @@ export function inferSemanticValueShapes(machine, authority) {
2127
2185
  });
2128
2186
  if (!admits(values))
2129
2187
  continue;
2188
+ // DR-091: an admitted string is recorded too, so only a field the
2189
+ // probe revealed nothing for goes unrecorded.
2130
2190
  const shapes = {};
2131
2191
  semanticFields.forEach((field, index) => {
2132
2192
  let shape = SEMANTIC_SHAPE_SAMPLES[combination[index]][0];
2133
2193
  if (shape === 'string-array' && admits({ ...values, [field]: [1] })) {
2134
2194
  shape = 'string-or-number-array';
2135
2195
  }
2136
- if (shape !== 'string')
2137
- shapes[field] = shape;
2196
+ shapes[field] = shape;
2138
2197
  });
2139
- if (Object.keys(shapes).length > 0) {
2140
- stateShapes[guard] = Object.freeze(shapes);
2141
- }
2198
+ stateShapes[guard] = Object.freeze(shapes);
2142
2199
  break;
2143
2200
  }
2144
2201
  }
@@ -2715,8 +2772,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
2715
2772
  }
2716
2773
  const composePlayerPrompt = spec.composePlayerPrompt ??
2717
2774
  ((input, _identity, resuming = false) => defaultComposePlayerPrompt(input, spec.placeholderFields, resuming));
2718
- const composeCaptainPrompt = spec.composeCaptainPrompt ??
2775
+ const composeArtifactCaptainPrompt = spec.composeCaptainPrompt ??
2719
2776
  ((input) => defaultComposeCaptainPrompt(input, spec.placeholderFields));
2777
+ // DR-090 §2: a direct-Captain continuation is marked by the runtime too.
2778
+ const composeCaptainPrompt = (input) => [
2779
+ ...markedContinuationBlocks(input),
2780
+ composeArtifactCaptainPrompt(withoutContinuation(input)),
2781
+ ].join('\n\n');
2720
2782
  const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
2721
2783
  const verbatimPayloadFields = new Set(spec.verbatimPayloadFields ?? NO_VERBATIM_FIELDS);
2722
2784
  const adjudication = {
@@ -2865,6 +2927,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
2865
2927
  const currentEffectLedger = () => assertPlaybookEffectLedger(effectLedgerCapability.snapshot(), `${label} current host effect ledger`);
2866
2928
  let effectLedgerMirror = currentEffectLedger();
2867
2929
  let recoveryCheckpoint;
2930
+ // DR-090 §1: the run's Boss exchanges, in order. A run begins at the
2931
+ // artifact's entry event; the list travels in the snapshot.
2932
+ let bossExchanges = [];
2868
2933
  let retainedEffectSourceSessionId;
2869
2934
  let retainedEffectReconciliation;
2870
2935
  let retainedEffectReconciliationRequired = false;
@@ -3495,7 +3560,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
3495
3560
  }
3496
3561
  return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
3497
3562
  }
3563
+ // DR-090 §3: the first block names the role and, where the host bound
3564
+ // it, the model the call runs on as configured, so a player can name
3565
+ // both where its project asks (a commit trailer, say).
3566
+ function playerIdentityBlock(roleId) {
3567
+ const binding = session?.roleBindings?.[roleId];
3568
+ return binding === undefined
3569
+ ? `Role: ${roleId}`
3570
+ : `Role: ${roleId} · Model: ${binding.promptIdentity}`;
3571
+ }
3498
3572
  function composeBoundPlayerPrompt(input, resuming = false) {
3573
+ const roleId = requireRoleId(input);
3499
3574
  let active = true;
3500
3575
  const lookup = (roleId) => {
3501
3576
  if (!active) {
@@ -3503,12 +3578,44 @@ export function createXStatePlaybookRuntime(machine, spec) {
3503
3578
  }
3504
3579
  return promptIdentity(roleId);
3505
3580
  };
3581
+ let body;
3506
3582
  try {
3507
- return composePlayerPrompt(input, lookup, resuming);
3583
+ body = composePlayerPrompt(withoutContinuation(input), lookup, resuming);
3508
3584
  }
3509
3585
  finally {
3510
3586
  active = false;
3511
3587
  }
3588
+ // DR-090 §1, §2: every role hears the Boss — the run's exchanges come
3589
+ // before the body whatever the role, and the asking role's marked
3590
+ // continuation carries the exchange it asked.
3591
+ return [
3592
+ playerIdentityBlock(roleId),
3593
+ ...bossExchangeBlocks(bossExchanges, input),
3594
+ ...markedContinuationBlocks(input, resuming),
3595
+ body,
3596
+ ].join('\n\n');
3597
+ }
3598
+ /** DR-090 §1: record a Boss reply the machine is about to receive. */
3599
+ function recordBossExchange(event, pending) {
3600
+ if (event.type !== 'BOSS_REPLY')
3601
+ return;
3602
+ const { questionId, answer } = event;
3603
+ if (typeof answer !== 'string' || answer.trim().length === 0)
3604
+ return;
3605
+ const question = pending.find((candidate) => candidate.questionId === questionId) ??
3606
+ (questionId === undefined && pending.length === 1 ? pending[0] : undefined);
3607
+ if (question === undefined)
3608
+ return;
3609
+ bossExchanges = Object.freeze([
3610
+ ...bossExchanges,
3611
+ Object.freeze({ question: question.question, reply: answer }),
3612
+ ]);
3613
+ }
3614
+ /** DR-090 §1: an entry event begins a new run, with no exchanges yet. */
3615
+ function beginRunIfEntry(event) {
3616
+ if (spec.entryEvent !== undefined && event.type === spec.entryEvent.type) {
3617
+ bossExchanges = [];
3618
+ }
3512
3619
  }
3513
3620
  function continuationKey(roleId, playerId) {
3514
3621
  return playerId ?? roleId;
@@ -4372,7 +4479,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
4372
4479
  try {
4373
4480
  await claim.assertOwner();
4374
4481
  const restored = snapshotJsonValue(await repositoryCapability.observe());
4375
- if (!isDeepStrictEqual(restored, saved.baseline)) {
4482
+ // DR-090 §4: the branch an observation names is never compared.
4483
+ const { branch: _restoredBranch, ...restoredState } = restored;
4484
+ const { branch: _savedBranch, ...savedState } = saved.baseline;
4485
+ if (!isDeepStrictEqual(restoredState, savedState)) {
4376
4486
  throw new Error(`${label} preparation has not restored the stopped step's repository; it remains paused`);
4377
4487
  }
4378
4488
  signal.throwIfAborted();
@@ -4580,6 +4690,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
4580
4690
  ...((state.stateId === 'failed' || pendingQuestions.length > 0) && recoveryCheckpoint !== undefined
4581
4691
  ? { recoveryCheckpoint } : {}),
4582
4692
  ...(suspendedCall === undefined ? {} : { suspendedCall }),
4693
+ ...(bossExchanges.length === 0
4694
+ ? {}
4695
+ : {
4696
+ bossExchanges: bossExchanges.map(({ question, reply }) => ({
4697
+ question,
4698
+ reply,
4699
+ })),
4700
+ }),
4583
4701
  };
4584
4702
  }
4585
4703
  async function runJournalledStep(kind, input, signal, execute) {
@@ -6354,6 +6472,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6354
6472
  deferInspectionEmissions = false;
6355
6473
  deferredInspectionEmissions = [];
6356
6474
  recoveryCheckpoint = undefined;
6475
+ bossExchanges = [];
6357
6476
  suppressInspectionEmissions = false;
6358
6477
  initialized = false;
6359
6478
  traceSequence = 0;
@@ -6706,6 +6825,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
6706
6825
  }
6707
6826
  restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
6708
6827
  }
6828
+ // DR-090 §1: a restored or adopted run keeps the Boss exchanges it
6829
+ // heard; a snapshot without them heard none.
6830
+ bossExchanges = Object.freeze((boundSnapshot.bossExchanges ?? []).map(({ question, reply }) => Object.freeze({ question, reply })));
6709
6831
  nestedBridge.prepareRestore(suspendedCall);
6710
6832
  if (suspendedCall !== undefined) {
6711
6833
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -7671,7 +7793,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
7671
7793
  deferredOperation !== undefined) {
7672
7794
  if (isExactDeferredBossReply(classifiedSnapshot, event, deferredPending)) {
7673
7795
  const statusLine = classificationStatus(event);
7674
- const continuation = await continueBoundDeferredOperation(deferredOperation, event, signal, turnId, statusLine, onAccepted);
7796
+ const acceptedPending = deferredPending;
7797
+ const acceptedEvent = event;
7798
+ const continuation = await continueBoundDeferredOperation(deferredOperation, event, signal, turnId, statusLine, () => {
7799
+ // DR-090 §1: the host started the continuation, so the
7800
+ // machine receives this reply now.
7801
+ recordBossExchange(acceptedEvent, [acceptedPending]);
7802
+ onAccepted?.();
7803
+ });
7675
7804
  if (continuation === 'continued') {
7676
7805
  if (controlPlaneError !== undefined) {
7677
7806
  throw controlPlaneError;
@@ -7715,13 +7844,21 @@ export function createXStatePlaybookRuntime(machine, spec) {
7715
7844
  signal.throwIfAborted();
7716
7845
  // 3. A final actor cannot accept new events; reconstruct only
7717
7846
  // after classification produced a real event.
7847
+ const acceptedPending = classifiedSnapshot === undefined
7848
+ ? []
7849
+ : pendingBossQuestionsFor(normalizePlaybookSnapshot(classifiedSnapshot), (classifiedSnapshot.context ??
7850
+ {}));
7718
7851
  if (actor.getSnapshot().status === 'done') {
7719
7852
  stopActor();
7720
7853
  actor = buildActor(runtimePorts);
7721
7854
  // The replacement actor's snapshots are real state entries.
7722
7855
  suppressInspectionEmissions = false;
7723
7856
  actor.start();
7857
+ // DR-090 §1: a finished run's exchanges end with it.
7858
+ bossExchanges = [];
7724
7859
  }
7860
+ beginRunIfEntry(event);
7861
+ recordBossExchange(event, acceptedPending);
7725
7862
  onAccepted?.();
7726
7863
  actor.send(event);
7727
7864
  await waitForPlaybookQuiescence(actor, {
@@ -8002,6 +8139,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
8002
8139
  failedGovernedAttemptId = undefined;
8003
8140
  controlPlaneError = undefined;
8004
8141
  emissionFailure = undefined;
8142
+ bossExchanges = [];
8005
8143
  retainedEffectSourceSessionId = undefined;
8006
8144
  retainedEffectReconciliation = undefined;
8007
8145
  retainedEffectReconciliationRequired = false;
@@ -58,6 +58,7 @@ import type {
58
58
  CaptainResult,
59
59
  JsonValue,
60
60
  PlaybookAdoptionContext,
61
+ PlaybookBossExchange,
61
62
  PlaybookCallResult,
62
63
  PlaybookControlAction,
63
64
  PlaybookControlReceipt,
@@ -1327,6 +1328,9 @@ function pendingBossQuestionRecord(
1327
1328
  const CONTINUATION_PREAMBLE =
1328
1329
  'Continue the same task using Boss’s reply below.';
1329
1330
 
1331
+ // The labelled continuation a compiled artifact's composer renders, which
1332
+ // slc's prompt-contract verifier pins. The runtime no longer sends it: it
1333
+ // hands a composer no question and reply, and marks them itself (DR-090 §2).
1330
1334
  function continuationBlocks(input: {
1331
1335
  pendingBossQuestion?: { readonly question: string };
1332
1336
  bossReply?: string;
@@ -1341,6 +1345,76 @@ function continuationBlocks(input: {
1341
1345
  ];
1342
1346
  }
1343
1347
 
1348
+ // DR-090 §2: a Boss exchange reaches a player as structure, not as an
1349
+ // English instruction — a marker line before each question and each reply,
1350
+ // whatever language the run speaks.
1351
+ const BOSS_QUESTION_MARKER = '? ⟶';
1352
+ const BOSS_REPLY_MARKER = 'Boss ⟶';
1353
+
1354
+ function bossQuestionBlock(question: string): string {
1355
+ return `${BOSS_QUESTION_MARKER}\n${question}`;
1356
+ }
1357
+
1358
+ function bossReplyBlock(reply: string): string {
1359
+ return `${BOSS_REPLY_MARKER}\n${reply}`;
1360
+ }
1361
+
1362
+ /** DR-090 §2: the marked continuation of a call after a Boss reply. */
1363
+ function markedContinuationBlocks(input: {
1364
+ pendingBossQuestion?: { readonly question: string };
1365
+ bossReply?: string;
1366
+ }, resuming = false): string[] {
1367
+ if (input.pendingBossQuestion === undefined || input.bossReply === undefined) {
1368
+ return [];
1369
+ }
1370
+ return [
1371
+ ...(resuming ? [] : [bossQuestionBlock(input.pendingBossQuestion.question)]),
1372
+ bossReplyBlock(input.bossReply),
1373
+ ];
1374
+ }
1375
+
1376
+ /**
1377
+ * The input a composer receives: without the question and reply, which the
1378
+ * runtime marks itself (DR-090 §2), so a composer adds no labelled blocks.
1379
+ */
1380
+ function withoutContinuation<T extends {
1381
+ pendingBossQuestion?: unknown;
1382
+ bossReply?: unknown;
1383
+ }>(input: T): T {
1384
+ if (input.pendingBossQuestion === undefined && input.bossReply === undefined) {
1385
+ return input;
1386
+ }
1387
+ const { pendingBossQuestion: _question, bossReply: _reply, ...rest } = input;
1388
+ return rest as T;
1389
+ }
1390
+
1391
+ /**
1392
+ * DR-090 §1: the run's Boss exchanges a player call carries before its body,
1393
+ * leaving out the one exchange the call's own continuation blocks carry, so
1394
+ * the asking role reads its answered question once.
1395
+ */
1396
+ function bossExchangeBlocks(
1397
+ exchanges: readonly PlaybookBossExchange[],
1398
+ input: Pick<PlaybookPlayerInput, 'pendingBossQuestion' | 'bossReply'>,
1399
+ ): string[] {
1400
+ let carried = -1;
1401
+ if (input.pendingBossQuestion !== undefined && input.bossReply !== undefined) {
1402
+ const question = input.pendingBossQuestion.question;
1403
+ for (let index = exchanges.length - 1; index >= 0; index -= 1) {
1404
+ const exchange = exchanges[index]!;
1405
+ if (exchange.question === question && exchange.reply === input.bossReply) {
1406
+ carried = index;
1407
+ break;
1408
+ }
1409
+ }
1410
+ }
1411
+ return exchanges.flatMap((exchange, index) =>
1412
+ index === carried
1413
+ ? []
1414
+ : [bossQuestionBlock(exchange.question), bossReplyBlock(exchange.reply)],
1415
+ );
1416
+ }
1417
+
1344
1418
  /** Add clarification context without repeating the question in a live conversation. */
1345
1419
  export function composePlayerContinuation(
1346
1420
  input: Pick<PlaybookPlayerInput, 'pendingBossQuestion' | 'bossReply'>,
@@ -3273,8 +3347,9 @@ function snapshotOutcomeAuthority(
3273
3347
  // the machine's transition selection — guards only, no action runs — with
3274
3348
  // sample values, and the first combination of shapes whose arm carries that
3275
3349
  // outcome's accepted-outcome marker is the contract the judge is shown and the
3276
- // reconciler enforces. A field whose shape the machine does not reveal, or
3277
- // that it admits as a string, stays a string.
3350
+ // reconciler enforces. A field the machine admits as a string is recorded as
3351
+ // one; a field whose shape it does not reveal is recorded with none, so the
3352
+ // reconciler leaves its value to the machine's own validator (DR-091).
3278
3353
  // ---------------------------------------------------------------------------
3279
3354
 
3280
3355
  const SEMANTIC_SHAPE_SAMPLES: readonly (readonly [
@@ -3337,9 +3412,10 @@ function shapeCombinations(fieldCount: number): readonly number[][] {
3337
3412
  }
3338
3413
 
3339
3414
  /**
3340
- * DR-089: the non-string JSON value shape the compiled machine admits for
3341
- * each semantic-owned field of each governed outcome, keyed by state id and
3342
- * then by outcome. Exported so linked machinery of an artifact's own reads
3415
+ * DR-089: the JSON value shape the compiled machine admits for each
3416
+ * semantic-owned field of each governed outcome, keyed by state id and then
3417
+ * by outcome. A field, outcome, or state the probe reveals no shape for is
3418
+ * left out (DR-091). Exported so linked machinery of an artifact's own reads
3343
3419
  * the same contract the shared factory enforces.
3344
3420
  */
3345
3421
  export function inferSemanticValueShapes(
@@ -3425,17 +3501,17 @@ export function inferSemanticValueShapes(
3425
3501
  values[field] = SEMANTIC_SHAPE_SAMPLES[combination[index]!]![1];
3426
3502
  });
3427
3503
  if (!admits(values)) continue;
3504
+ // DR-091: an admitted string is recorded too, so only a field the
3505
+ // probe revealed nothing for goes unrecorded.
3428
3506
  const shapes: Record<string, PlaybookSemanticValueShape> = {};
3429
3507
  semanticFields.forEach((field, index) => {
3430
3508
  let shape = SEMANTIC_SHAPE_SAMPLES[combination[index]!]![0];
3431
3509
  if (shape === 'string-array' && admits({ ...values, [field]: [1] })) {
3432
3510
  shape = 'string-or-number-array';
3433
3511
  }
3434
- if (shape !== 'string') shapes[field] = shape;
3512
+ shapes[field] = shape;
3435
3513
  });
3436
- if (Object.keys(shapes).length > 0) {
3437
- stateShapes[guard] = Object.freeze(shapes);
3438
- }
3514
+ stateShapes[guard] = Object.freeze(shapes);
3439
3515
  break;
3440
3516
  }
3441
3517
  }
@@ -4269,10 +4345,16 @@ export function createXStatePlaybookRuntime<
4269
4345
  spec.composePlayerPrompt ??
4270
4346
  ((input: PlaybookPlayerInput, _identity: XStatePromptIdentity, resuming = false) =>
4271
4347
  defaultComposePlayerPrompt(input, spec.placeholderFields, resuming));
4272
- const composeCaptainPrompt =
4348
+ const composeArtifactCaptainPrompt =
4273
4349
  spec.composeCaptainPrompt ??
4274
4350
  ((input: PlaybookCaptainInput) =>
4275
4351
  defaultComposeCaptainPrompt(input, spec.placeholderFields));
4352
+ // DR-090 §2: a direct-Captain continuation is marked by the runtime too.
4353
+ const composeCaptainPrompt = (input: PlaybookCaptainInput): string =>
4354
+ [
4355
+ ...markedContinuationBlocks(input),
4356
+ composeArtifactCaptainPrompt(withoutContinuation(input)),
4357
+ ].join('\n\n');
4276
4358
  const extractFields =
4277
4359
  spec.extractRequiredFields ?? defaultExtractRequiredFields;
4278
4360
  const verbatimPayloadFields: ReadonlySet<string> = new Set(
@@ -4508,6 +4590,9 @@ export function createXStatePlaybookRuntime<
4508
4590
  );
4509
4591
  let effectLedgerMirror = currentEffectLedger();
4510
4592
  let recoveryCheckpoint: PlaybookRuntimeSnapshot['recoveryCheckpoint'];
4593
+ // DR-090 §1: the run's Boss exchanges, in order. A run begins at the
4594
+ // artifact's entry event; the list travels in the snapshot.
4595
+ let bossExchanges: readonly PlaybookBossExchange[] = [];
4511
4596
  let retainedEffectSourceSessionId: string | undefined;
4512
4597
  let retainedEffectReconciliation:
4513
4598
  | PlaybookRuntimeSnapshot['retainedEffectReconciliation']
@@ -5406,7 +5491,18 @@ export function createXStatePlaybookRuntime<
5406
5491
  return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
5407
5492
  }
5408
5493
 
5494
+ // DR-090 §3: the first block names the role and, where the host bound
5495
+ // it, the model the call runs on as configured, so a player can name
5496
+ // both where its project asks (a commit trailer, say).
5497
+ function playerIdentityBlock(roleId: string): string {
5498
+ const binding = session?.roleBindings?.[roleId];
5499
+ return binding === undefined
5500
+ ? `Role: ${roleId}`
5501
+ : `Role: ${roleId} · Model: ${binding.promptIdentity}`;
5502
+ }
5503
+
5409
5504
  function composeBoundPlayerPrompt(input: PlaybookPlayerInput, resuming = false): string {
5505
+ const roleId = requireRoleId(input);
5410
5506
  let active = true;
5411
5507
  const lookup: XStatePromptIdentity = (roleId) => {
5412
5508
  if (!active) {
@@ -5416,11 +5512,49 @@ export function createXStatePlaybookRuntime<
5416
5512
  }
5417
5513
  return promptIdentity(roleId);
5418
5514
  };
5515
+ let body: string;
5419
5516
  try {
5420
- return composePlayerPrompt(input, lookup, resuming);
5517
+ body = composePlayerPrompt(withoutContinuation(input), lookup, resuming);
5421
5518
  } finally {
5422
5519
  active = false;
5423
5520
  }
5521
+ // DR-090 §1, §2: every role hears the Boss — the run's exchanges come
5522
+ // before the body whatever the role, and the asking role's marked
5523
+ // continuation carries the exchange it asked.
5524
+ return [
5525
+ playerIdentityBlock(roleId),
5526
+ ...bossExchangeBlocks(bossExchanges, input),
5527
+ ...markedContinuationBlocks(input, resuming),
5528
+ body,
5529
+ ].join('\n\n');
5530
+ }
5531
+
5532
+ /** DR-090 §1: record a Boss reply the machine is about to receive. */
5533
+ function recordBossExchange(
5534
+ event: EventObject,
5535
+ pending: readonly PlaybookPendingBossQuestionContext[],
5536
+ ): void {
5537
+ if (event.type !== 'BOSS_REPLY') return;
5538
+ const { questionId, answer } = event as {
5539
+ readonly questionId?: unknown;
5540
+ readonly answer?: unknown;
5541
+ };
5542
+ if (typeof answer !== 'string' || answer.trim().length === 0) return;
5543
+ const question =
5544
+ pending.find((candidate) => candidate.questionId === questionId) ??
5545
+ (questionId === undefined && pending.length === 1 ? pending[0] : undefined);
5546
+ if (question === undefined) return;
5547
+ bossExchanges = Object.freeze([
5548
+ ...bossExchanges,
5549
+ Object.freeze({ question: question.question, reply: answer }),
5550
+ ]);
5551
+ }
5552
+
5553
+ /** DR-090 §1: an entry event begins a new run, with no exchanges yet. */
5554
+ function beginRunIfEntry(event: EventObject): void {
5555
+ if (spec.entryEvent !== undefined && event.type === spec.entryEvent.type) {
5556
+ bossExchanges = [];
5557
+ }
5424
5558
  }
5425
5559
 
5426
5560
  function continuationKey(
@@ -6706,7 +6840,10 @@ export function createXStatePlaybookRuntime<
6706
6840
  try {
6707
6841
  await claim.assertOwner();
6708
6842
  const restored = snapshotJsonValue(await repositoryCapability!.observe!()) as unknown as PlaybookRepositoryReceipt['baseline'];
6709
- if (!isDeepStrictEqual(restored, saved.baseline)) {
6843
+ // DR-090 §4: the branch an observation names is never compared.
6844
+ const { branch: _restoredBranch, ...restoredState } = restored;
6845
+ const { branch: _savedBranch, ...savedState } = saved.baseline;
6846
+ if (!isDeepStrictEqual(restoredState, savedState)) {
6710
6847
  throw new Error(`${label} preparation has not restored the stopped step's repository; it remains paused`);
6711
6848
  }
6712
6849
  signal.throwIfAborted();
@@ -6954,6 +7091,14 @@ export function createXStatePlaybookRuntime<
6954
7091
  ...((state.stateId === 'failed' || pendingQuestions.length > 0) && recoveryCheckpoint !== undefined
6955
7092
  ? { recoveryCheckpoint } : {}),
6956
7093
  ...(suspendedCall === undefined ? {} : { suspendedCall }),
7094
+ ...(bossExchanges.length === 0
7095
+ ? {}
7096
+ : {
7097
+ bossExchanges: bossExchanges.map(({ question, reply }) => ({
7098
+ question,
7099
+ reply,
7100
+ })),
7101
+ }),
6957
7102
  };
6958
7103
  }
6959
7104
 
@@ -9224,6 +9369,7 @@ export function createXStatePlaybookRuntime<
9224
9369
  deferInspectionEmissions = false;
9225
9370
  deferredInspectionEmissions = [];
9226
9371
  recoveryCheckpoint = undefined;
9372
+ bossExchanges = [];
9227
9373
  suppressInspectionEmissions = false;
9228
9374
  initialized = false;
9229
9375
  traceSequence = 0;
@@ -9671,6 +9817,13 @@ export function createXStatePlaybookRuntime<
9671
9817
  }
9672
9818
  restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
9673
9819
  }
9820
+ // DR-090 §1: a restored or adopted run keeps the Boss exchanges it
9821
+ // heard; a snapshot without them heard none.
9822
+ bossExchanges = Object.freeze(
9823
+ (boundSnapshot.bossExchanges ?? []).map(({ question, reply }) =>
9824
+ Object.freeze({ question, reply }),
9825
+ ),
9826
+ );
9674
9827
  nestedBridge.prepareRestore(suspendedCall);
9675
9828
  if (suspendedCall !== undefined) {
9676
9829
  playbookCallTurnIds.set(
@@ -10870,13 +11023,20 @@ export function createXStatePlaybookRuntime<
10870
11023
  )
10871
11024
  ) {
10872
11025
  const statusLine = classificationStatus(event);
11026
+ const acceptedPending = deferredPending;
11027
+ const acceptedEvent = event;
10873
11028
  const continuation = await continueBoundDeferredOperation(
10874
11029
  deferredOperation,
10875
11030
  event,
10876
11031
  signal,
10877
11032
  turnId,
10878
11033
  statusLine,
10879
- onAccepted,
11034
+ () => {
11035
+ // DR-090 §1: the host started the continuation, so the
11036
+ // machine receives this reply now.
11037
+ recordBossExchange(acceptedEvent, [acceptedPending]);
11038
+ onAccepted?.();
11039
+ },
10880
11040
  );
10881
11041
  if (continuation === 'continued') {
10882
11042
  if (controlPlaneError !== undefined) {
@@ -10921,13 +11081,25 @@ export function createXStatePlaybookRuntime<
10921
11081
  signal.throwIfAborted();
10922
11082
  // 3. A final actor cannot accept new events; reconstruct only
10923
11083
  // after classification produced a real event.
11084
+ const acceptedPending =
11085
+ classifiedSnapshot === undefined
11086
+ ? []
11087
+ : pendingBossQuestionsFor(
11088
+ normalizePlaybookSnapshot(classifiedSnapshot),
11089
+ ((classifiedSnapshot as { context?: unknown }).context ??
11090
+ {}) as Record<string, unknown>,
11091
+ );
10924
11092
  if (actor.getSnapshot().status === 'done') {
10925
11093
  stopActor();
10926
11094
  actor = buildActor(runtimePorts!);
10927
11095
  // The replacement actor's snapshots are real state entries.
10928
11096
  suppressInspectionEmissions = false;
10929
11097
  actor.start();
11098
+ // DR-090 §1: a finished run's exchanges end with it.
11099
+ bossExchanges = [];
10930
11100
  }
11101
+ beginRunIfEntry(event);
11102
+ recordBossExchange(event, acceptedPending);
10931
11103
  onAccepted?.();
10932
11104
  actor.send(event);
10933
11105
  await waitForPlaybookQuiescence(actor, {
@@ -11236,6 +11408,7 @@ export function createXStatePlaybookRuntime<
11236
11408
  failedGovernedAttemptId = undefined;
11237
11409
  controlPlaneError = undefined;
11238
11410
  emissionFailure = undefined;
11411
+ bossExchanges = [];
11239
11412
  retainedEffectSourceSessionId = undefined;
11240
11413
  retainedEffectReconciliation = undefined;
11241
11414
  retainedEffectReconciliationRequired = false;
@@ -56,13 +56,14 @@ export interface PlaybookSemanticOutcomeSpec {
56
56
  }
57
57
  /**
58
58
  * The JSON value shape a compiled machine admits for one semantic-owned field
59
- * (DR-089). A field the machine admits as a string, or whose shape the
60
- * machine does not reveal, is `string`.
59
+ * (DR-089). A field the machine admits as a string is `string`; a field whose
60
+ * shape the machine does not reveal has none (DR-091).
61
61
  */
62
62
  export type PlaybookSemanticValueShape = 'string' | 'string-array' | 'number-array' | 'string-or-number-array' | 'number' | 'boolean';
63
63
  /**
64
- * Per outcome, the non-string shapes of its semantic-owned fields; a field
65
- * left out is a string.
64
+ * Per outcome, the shapes of its semantic-owned fields; a field left out has
65
+ * no revealed shape and takes any JSON value, the machine's own validator
66
+ * deciding it (DR-091).
66
67
  */
67
68
  export type PlaybookSemanticValueShapes = Readonly<Record<string, Readonly<Record<string, PlaybookSemanticValueShape>>>>;
68
69
  /** The words a judge prompt or validation error uses for one value shape. */
@@ -76,7 +77,8 @@ export interface PlaybookSemanticEvidenceInput {
76
77
  readonly runtimeFields?: unknown;
77
78
  /**
78
79
  * DR-089: the shape the compiled machine admits for each semantic-owned
79
- * field, keyed by outcome; every field it leaves out is a string.
80
+ * field, keyed by outcome; a field it leaves out takes any JSON value
81
+ * (DR-091).
80
82
  */
81
83
  readonly semanticValueShapes?: PlaybookSemanticValueShapes;
82
84
  }