@sublang/playbook 17.3.0 → 17.4.1

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 (50) hide show
  1. package/README.md +5 -2
  2. package/docs/configuration.md +45 -4
  3. package/docs/embedding.md +94 -0
  4. package/package.json +34 -5
  5. package/reference/sdlc/captain.md +6 -4
  6. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
  7. package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
  8. package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
  9. package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
  10. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
  11. package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
  12. package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
  13. package/reference/sdlc/code.md +24 -4
  14. package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
  15. package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
  16. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  17. package/reference/sdlc/code.playbook/bin/run.js +68 -4
  18. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  19. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  20. package/reference/sdlc/code.playbook/bin/session-store.js +145 -13
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +12 -0
  22. package/reference/sdlc/code.playbook/code.fsm.js +40 -6
  23. package/reference/sdlc/code.playbook/code.fsm.ts +53 -6
  24. package/reference/sdlc/code.playbook/code.gears.md +32 -4
  25. package/reference/sdlc/code.playbook/playbook-captain.d.ts +17 -1
  26. package/reference/sdlc/code.playbook/playbook-captain.js +201 -19
  27. package/reference/sdlc/code.playbook/playbook-captain.ts +224 -24
  28. package/reference/sdlc/code.playbook/playbook.config.template.yaml +16 -3
  29. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  30. package/reference/sdlc/code.playbook/session-assets.js +303 -0
  31. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  32. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  33. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  34. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  35. package/reference/sdlc/inspect.md +40 -0
  36. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  37. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  38. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  39. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  40. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  41. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  42. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  43. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  44. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  45. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  46. package/slc/gears2fsm.md +17 -3
  47. package/slc/text2gears.md +7 -0
  48. package/slc/workflow-contracts.json +43 -1
  49. package/src/xstate-playbook-runtime.js +5 -0
  50. package/src/xstate-playbook-runtime.ts +7 -0
@@ -61,8 +61,14 @@ import createDefaultCaptainRuntime, {
61
61
  type SettlementEvidence,
62
62
  } from '../captain.playbook/captain.playbook.js';
63
63
  import type { PlaybookSummaryPolicy } from './code.registry.js';
64
+ import type { Attachment } from '@sublang/cligent';
65
+ import { validateAssetRef, validateAssetRefs } from './session-assets.js';
66
+ import type { SessionAssetRef } from './session-assets.js';
67
+ import { QUOTED_EVIDENCE_LIMIT, workerReportExcerpt, workerReportBlock } from './worker-evidence.js';
64
68
 
65
69
  interface SessionAgent {
70
+ /** Mutable next-turn execution capability; never structural identity. */
71
+ readonly browser?: boolean;
66
72
  readonly adapter: string;
67
73
  readonly model: TuningSelection;
68
74
  readonly effort: TuningSelection<Effort>;
@@ -121,13 +127,13 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
121
127
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
122
128
  }
123
129
 
124
- type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey>>;
130
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey | 'browser'>>;
125
131
 
126
132
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
127
133
 
128
134
  export interface ProgressChange {
129
135
  snapshot?: PlaybookCaptainShellSnapshot | null;
130
- step?: Omit<PlaybookStepRecord, 'kind'> & { kind: PlaybookStepRecord['kind'] | 'preparation' | 'completion' | 'answer'; runtimeSessionId: string; playbookId: string };
136
+ step?: Omit<PlaybookStepRecord, 'kind'> & { kind: PlaybookStepRecord['kind'] | 'preparation' | 'completion' | 'answer'; runtimeSessionId: string; playbookId: string; workerEvidence?: { boundaryId: string; playerId: string } };
131
137
  }
132
138
  export interface InterruptedReport {
133
139
  text: string;
@@ -137,6 +143,9 @@ export interface InterruptedReport {
137
143
  }
138
144
 
139
145
  export interface PlaybookCaptainDeps {
146
+ onTurnAttachments?: (references: readonly SessionAssetRef[]) => void;
147
+ beginPreparationMedia?: (runtimeSessionId: string) => () => void;
148
+ resolveAttachments?: (references: readonly SessionAssetRef[], signal: AbortSignal) => Promise<readonly Attachment[]>;
140
149
  /** Stop the host's active turn, including admitted tool calls, on preparation expiry or a required save failure. */
141
150
  abortPreparation?: (reason?: string) => void;
142
151
  recordProgress?: (change: ProgressChange) => Promise<void>;
@@ -259,6 +268,7 @@ export interface PlaybookCaptainFrameSnapshot {
259
268
  readonly parentCallId?: string;
260
269
  readonly request?: string;
261
270
  readonly inputs?: readonly string[];
271
+ readonly attachments?: readonly SessionAssetRef[];
262
272
  readonly options: JsonValue;
263
273
  readonly roleBindings: Readonly<Record<string, string>>;
264
274
  readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
@@ -282,6 +292,7 @@ interface PlaybookCaptainShellSnapshotFields {
282
292
  readonly journal: number;
283
293
  };
284
294
  readonly journal: readonly PlaybookCaptainJournalRecord[];
295
+ readonly pendingAttachments?: readonly SessionAssetRef[];
285
296
  readonly lastAction?:
286
297
  | 'respond'
287
298
  | 'start'
@@ -388,6 +399,9 @@ export interface PlaybookCaptainSettlement {
388
399
 
389
400
  /** tmux and headless front ends share this one durable Captain shell API. */
390
401
  export interface PlaybookCaptainShell extends Captain {
402
+ /** Bind already-owned evidence to the next visible Boss turn without changing its text. */
403
+ setTurnAttachments(references: readonly SessionAssetRef[]): void;
404
+ recordMediaEvidence(reference: SessionAssetRef, runtimeSessionId?: string): void;
391
405
  installRetainedGenerations(
392
406
  generations: Readonly<
393
407
  Record<string, PlaybookCaptainRetainedGeneration>
@@ -432,12 +446,27 @@ export interface PlaybookCaptainShell extends Captain {
432
446
  // normalized `captain.options.playbooks.<id>` role map.
433
447
  interface Enablement {
434
448
  entry: PlaybookCaptainRegistryEntry;
449
+ readonly configuredFrom: string;
435
450
  artifactSchema: 3;
436
451
  command: string;
437
452
  options: JsonValue;
438
453
  roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
439
454
  }
440
455
 
456
+ function missingPackagedReview(
457
+ enablementById: ReadonlyMap<string, Enablement>,
458
+ playbookId: string,
459
+ ): string | undefined {
460
+ const selected = enablementById.get(playbookId);
461
+ if (
462
+ selected === undefined ||
463
+ enablementById.has('review') ||
464
+ !['code', 'decide'].includes(playbookId) ||
465
+ selected.configuredFrom !== `@sublang/playbook/${playbookId}/registry`
466
+ ) return undefined;
467
+ return `/${selected.command} requires enabled playbook "review"; enable REVIEW before starting this packaged workflow`;
468
+ }
469
+
441
470
  function createRuntimeForEnablement(
442
471
  enablement: Enablement,
443
472
  hostCapabilitiesById: ReadonlyMap<
@@ -488,6 +517,8 @@ type PlayerTransaction =
488
517
  });
489
518
 
490
519
  interface EngagementFrame {
520
+ effectOwnerSessionId?: string;
521
+ attachments?: readonly SessionAssetRef[];
491
522
  request?: string;
492
523
  inputs?: string[];
493
524
  pauseCancellation?: () => () => void;
@@ -677,6 +708,12 @@ interface ActiveTurnSummary {
677
708
 
678
709
  /** The shell state of one Boss turn (DR-029). */
679
710
  interface ActiveTurn {
711
+ readonly workerReports: Map<string, string>;
712
+ readonly workerStepStarts: Map<string, ReadonlySet<string>>;
713
+ readonly workerCalls: Map<string, { frame: EngagementFrame; callId: string; roleId: string; playerId: string; stateId: string; finalText: string }>;
714
+ evidence?: readonly SessionAssetRef[];
715
+ attachments: readonly SessionAssetRef[];
716
+ selectedAttachments?: readonly SessionAssetRef[];
680
717
  appliedActionCount: number;
681
718
  startedEngagement?: boolean;
682
719
  countedSummary?: ActiveTurnSummary;
@@ -847,7 +884,6 @@ function quoteEvidence(text: string): string {
847
884
  // into a fact and still knows it is foreign — never at the digest renderer,
848
885
  // which sees an opaque payload and cannot tell quoted output from the Boss
849
886
  // text, captain speech, or settlement facts the shell authored itself.
850
- const QUOTED_EVIDENCE_LIMIT = 400;
851
887
 
852
888
  // The same guard for strings the shell interpolates into a single-line fact:
853
889
  // control characters collapse to spaces so a quoted message can never open a
@@ -3011,6 +3047,7 @@ export function assertPlaybookCaptainShellSnapshot(
3011
3047
  'issuedSessionIds',
3012
3048
  'sequences',
3013
3049
  'journal',
3050
+ 'pendingAttachments',
3014
3051
  'lastAction',
3015
3052
  'lastSettlementStatus',
3016
3053
  'mode',
@@ -3339,6 +3376,7 @@ export function assertPlaybookCaptainShellSnapshot(
3339
3376
  issuedSessionIds: issued,
3340
3377
  sequences: { turn: turnSequence, journal: journalSequence },
3341
3378
  journal: normalizedJournal,
3379
+ ...(snapshot.pendingAttachments === undefined ? {} : { pendingAttachments: validateAssetRefs(snapshot.pendingAttachments) }),
3342
3380
  ...(lastAction === undefined ? {} : { lastAction }),
3343
3381
  ...(lastSettlementStatus === undefined
3344
3382
  ? {}
@@ -3390,6 +3428,7 @@ export function assertPlaybookCaptainShellSnapshot(
3390
3428
  'parentCallId',
3391
3429
  'request',
3392
3430
  'inputs',
3431
+ 'attachments',
3393
3432
  'options',
3394
3433
  'roleBindings',
3395
3434
  'runtime',
@@ -3447,6 +3486,7 @@ export function assertPlaybookCaptainShellSnapshot(
3447
3486
  ...(parentCallId === undefined ? {} : { parentCallId }),
3448
3487
  ...(frame.request === undefined ? {} : { request: frame.request as string }),
3449
3488
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs as string[] }),
3489
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
3450
3490
  options,
3451
3491
  roleBindings,
3452
3492
  runtime,
@@ -3763,6 +3803,7 @@ function snapshotSessionAgent(
3763
3803
  'fastMode',
3764
3804
  'subagentModel',
3765
3805
  'subagentEffort',
3806
+ 'browser',
3766
3807
  'instruction',
3767
3808
  'permissions',
3768
3809
  ],
@@ -3776,7 +3817,8 @@ function snapshotSessionAgent(
3776
3817
  key !== 'effort' &&
3777
3818
  key !== 'fastMode' &&
3778
3819
  key !== 'subagentModel' &&
3779
- key !== 'subagentEffort',
3820
+ key !== 'subagentEffort' &&
3821
+ key !== 'browser',
3780
3822
  ),
3781
3823
  ) as JsonValue,
3782
3824
  path,
@@ -3789,6 +3831,7 @@ function snapshotSessionAgent(
3789
3831
  ...(fixed.permissions === undefined
3790
3832
  ? {}
3791
3833
  : { permissions: livePermissions(fixed.permissions) }),
3834
+ ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
3792
3835
  model: snapshotTuningSelection(agent.model, `${path}.model`),
3793
3836
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
3794
3837
  ...(agent.fastMode === undefined
@@ -3813,7 +3856,7 @@ function snapshotSessionAgent(
3813
3856
  };
3814
3857
  }
3815
3858
 
3816
- function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey> {
3859
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey | 'browser'> {
3817
3860
  return {
3818
3861
  adapter: agent.adapter,
3819
3862
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
@@ -3833,6 +3876,7 @@ function callSettings(
3833
3876
  return {
3834
3877
  model: tuning.model,
3835
3878
  effort: tuning.effort,
3879
+ browser: agent.browser ?? false,
3836
3880
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
3837
3881
  ...(tuning.subagentModel === undefined
3838
3882
  ? {}
@@ -3845,6 +3889,10 @@ function callSettings(
3845
3889
  };
3846
3890
  }
3847
3891
 
3892
+ function controlCallSettings(agent: SessionAgent): AgentCallSettings {
3893
+ return { ...callSettings(agent), browser: false, mcpServers: {} };
3894
+ }
3895
+
3848
3896
  function promptIdentity(binding: EffectivePlayerBinding): string {
3849
3897
  return binding.model.kind === 'value'
3850
3898
  ? binding.model.value
@@ -4148,6 +4196,7 @@ async function buildEnablements(
4148
4196
  hostCapabilitiesById.set(entry.id, hostCapability);
4149
4197
  enablementById.set(entry.id, {
4150
4198
  entry,
4199
+ configuredFrom: from,
4151
4200
  artifactSchema,
4152
4201
  command,
4153
4202
  options: validatedOptions,
@@ -4421,6 +4470,17 @@ export function createPlaybookCaptainShell(
4421
4470
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
4422
4471
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
4423
4472
  const continuity = deps.continuity;
4473
+ const resolveAttachments = deps.resolveAttachments;
4474
+ const beginPreparationMedia = deps.beginPreparationMedia;
4475
+ const onTurnAttachments = deps.onTurnAttachments;
4476
+ let nextTurnAttachments: readonly SessionAssetRef[] = [];
4477
+ let pendingAttachments: readonly SessionAssetRef[] = [];
4478
+ const mergeAttachments = (...groups: readonly (readonly SessionAssetRef[])[]) => [...new Map(groups.flat().map((reference) => [reference.assetId, reference])).values()];
4479
+ const attachmentPaths = async (references: readonly SessionAssetRef[] | undefined, signal: AbortSignal) => {
4480
+ if (!references?.length) return undefined;
4481
+ if (!resolveAttachments) throw new Error('The session host cannot materialize required attachments');
4482
+ return resolveAttachments(references, signal);
4483
+ };
4424
4484
  const abortPreparation = deps.abortPreparation;
4425
4485
  let pendingHostCapabilities = deps.hostCapabilities;
4426
4486
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
@@ -4879,6 +4939,7 @@ export function createPlaybookCaptainShell(
4879
4939
  'parentCallId',
4880
4940
  'request',
4881
4941
  'inputs',
4942
+ 'attachments',
4882
4943
  'options',
4883
4944
  'roleBindings',
4884
4945
  'runtime',
@@ -5028,6 +5089,7 @@ export function createPlaybookCaptainShell(
5028
5089
  ...(parentCallId === undefined ? {} : { parentCallId }),
5029
5090
  ...(frame.request === undefined ? {} : { request: frame.request as string }),
5030
5091
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs as string[] }),
5092
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
5031
5093
  options,
5032
5094
  roleBindings,
5033
5095
  runtime,
@@ -5527,15 +5589,21 @@ export function createPlaybookCaptainShell(
5527
5589
  prompt: string,
5528
5590
  options: Parameters<CaptainContext['callCaptain']>[1],
5529
5591
  signal: AbortSignal,
5592
+ media?: { readonly attachments?: readonly SessionAssetRef[]; readonly preparation?: true },
5530
5593
  ): ReturnType<CaptainContext['callCaptain']> => {
5531
5594
  const queued = captainQueue.add(async () => {
5532
5595
  signal.throwIfAborted();
5533
- const result = await trackHostCall(
5534
- frame,
5535
- context.callCaptain(prompt, options),
5536
- );
5596
+ const attachments = media?.attachments?.length ? await attachmentPaths(media.attachments, signal) : undefined;
5537
5597
  signal.throwIfAborted();
5538
- return result;
5598
+ const endMedia = media?.preparation ? beginPreparationMedia?.(frame.sessionId) : undefined;
5599
+ try {
5600
+ const result = await trackHostCall(
5601
+ frame,
5602
+ context.callCaptain(prompt, { ...options, ...(attachments === undefined ? {} : { attachments }) }),
5603
+ );
5604
+ signal.throwIfAborted();
5605
+ return result;
5606
+ } finally { endMedia?.(); }
5539
5607
  });
5540
5608
  return trackHostCall(frame, queued);
5541
5609
  };
@@ -5621,12 +5689,20 @@ export function createPlaybookCaptainShell(
5621
5689
  !Number.isSafeInteger(trace.turnId) ||
5622
5690
  (trace.turnId as number) <= 0 ||
5623
5691
  !Number.isSafeInteger(trace.sequence) ||
5624
- (trace.sequence as number) <= 0 ||
5625
- summary === undefined ||
5626
- !summaryIncludes(frame)
5692
+ (trace.sequence as number) <= 0
5627
5693
  ) {
5628
5694
  return;
5629
5695
  }
5696
+ if (trace.type === 'player.call.finished') {
5697
+ const call = payloadRecord(trace.payload);
5698
+ if (typeof trace.callId === 'string' && call?.status === 'ok' &&
5699
+ typeof call.roleId === 'string' && typeof call.playerId === 'string' &&
5700
+ frame.playerBindings.get(call.roleId)?.playerId === call.playerId &&
5701
+ typeof call.stateId === 'string' && typeof call.finalText === 'string') {
5702
+ turn.workerCalls.set(`${frame.sessionId}:${trace.callId}`, { frame, callId: trace.callId, roleId: call.roleId, playerId: call.playerId, stateId: call.stateId, finalText: call.finalText });
5703
+ }
5704
+ return;
5705
+ }
5630
5706
  if (trace.type !== 'outcome.accepted') return;
5631
5707
  const receipt = exactOwnDataRecord(trace.payload, [
5632
5708
  'source',
@@ -5644,6 +5720,21 @@ export function createPlaybookCaptainShell(
5644
5720
  ) {
5645
5721
  return;
5646
5722
  }
5723
+ const ledger = currentEffectLedger();
5724
+ const previous = turn.progressBase?.effectLedger;
5725
+ for (const call of turn.workerCalls.values()) {
5726
+ if (call.frame !== frame || call.stateId !== receipt.source) continue;
5727
+ const boundary = [...ledger.boundaries].reverse().find((entry) =>
5728
+ entry.playbookId === frame.entry.id &&
5729
+ entry.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
5730
+ entry.callId === call.callId && entry.roleId === call.roleId &&
5731
+ entry.sourceStateId === call.stateId && entry.finalText === call.finalText &&
5732
+ !isDeepStrictEqual(entry, previous?.boundaries[entry.sequence - 1]));
5733
+ if (!boundary || turn.workerReports.has(boundary.boundaryId)) continue;
5734
+ const report = workerReportExcerpt(boundary, call.playerId, receipt.acceptedOutcome);
5735
+ if (report !== undefined) turn.workerReports.set(boundary.boundaryId, report);
5736
+ }
5737
+ if (summary === undefined || !summaryIncludes(frame)) return;
5647
5738
  const traceKey = `${frame.sessionId}:${trace.sequence}`;
5648
5739
  if (summary.acceptedOutcomeTraceKeys.has(traceKey)) return;
5649
5740
  summary.acceptedOutcomeTraceKeys.add(traceKey);
@@ -5671,8 +5762,28 @@ export function createPlaybookCaptainShell(
5671
5762
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
5672
5763
  ...(recordProgress === undefined ? {} : { recordStep: async (step: PlaybookStepRecord, position?: PlaybookRuntimeSnapshot) => {
5673
5764
  journalledFrames.add(frame);
5765
+ const turn = activeTurn;
5766
+ if (step.kind === 'player' && step.result === undefined) {
5767
+ turn?.workerStepStarts.set(step.id, new Set(turn.workerCalls.keys()));
5768
+ }
5769
+ let workerEvidence: { boundaryId: string; playerId: string } | undefined;
5770
+ const guard = payloadRecord(step.result)?.guard;
5771
+ const before = turn?.workerStepStarts.get(step.id);
5772
+ if (step.kind === 'player' && typeof guard === 'string' && before !== undefined && turn) {
5773
+ const candidates = [...turn.workerCalls.entries()].flatMap(([key, call]) => {
5774
+ if (before.has(key) || call.frame !== frame || call.stateId !== step.stateId) return [];
5775
+ return currentEffectLedger().boundaries.filter((boundary) =>
5776
+ boundary.playbookId === frame.entry.id && boundary.sourceStateId === step.stateId &&
5777
+ boundary.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
5778
+ boundary.callId === call.callId && boundary.roleId === call.roleId && boundary.finalText === call.finalText &&
5779
+ !isDeepStrictEqual(boundary, turn.progressBase?.effectLedger.boundaries[boundary.sequence - 1]) &&
5780
+ workerReportExcerpt(boundary, call.playerId, guard) !== undefined
5781
+ ).map((boundary) => ({ boundaryId: boundary.boundaryId, playerId: call.playerId }));
5782
+ });
5783
+ if (candidates.length === 1) workerEvidence = candidates[0];
5784
+ }
5674
5785
  await saveProgress(step.result === undefined ? { frame, position } : undefined,
5675
- { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id });
5786
+ { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id, ...(workerEvidence === undefined ? {} : { workerEvidence }) });
5676
5787
  } }),
5677
5788
  callPlayer: async (roleId, prompt, signal, options) => {
5678
5789
  admitHostBoundary();
@@ -5705,6 +5816,7 @@ export function createPlaybookCaptainShell(
5705
5816
  );
5706
5817
  }
5707
5818
  const settings = callSettings(binding.agent, binding);
5819
+ const attachments = await attachmentPaths(frame.attachments, signal);
5708
5820
  const calling: PlayerTransaction = {
5709
5821
  phase: 'calling',
5710
5822
  frame,
@@ -5731,7 +5843,7 @@ export function createPlaybookCaptainShell(
5731
5843
  const raw = await trackHostCall(
5732
5844
  frame,
5733
5845
  classifySettingsCall(() =>
5734
- context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings }),
5846
+ context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings, ...(attachments === undefined ? {} : { attachments }) }),
5735
5847
  ),
5736
5848
  );
5737
5849
  hostResolved = true;
@@ -5862,9 +5974,11 @@ export function createPlaybookCaptainShell(
5862
5974
  {
5863
5975
  visibility: options.visibility,
5864
5976
  resume: options.resume,
5977
+ settings: options.allowedTools?.length === 0 ? controlCallSettings(captainAgent!) : callSettings(captainAgent!),
5865
5978
  ...forwardedToolOptions(options.allowedTools, captainAdapter),
5866
5979
  },
5867
5980
  signal,
5981
+ { attachments: options.allowedTools?.length === 0 ? undefined : frame.attachments },
5868
5982
  );
5869
5983
  const output = {
5870
5984
  status: result.status,
@@ -5886,6 +6000,7 @@ export function createPlaybookCaptainShell(
5886
6000
  activeContext,
5887
6001
  hiddenJudgeEnvelope(prompt),
5888
6002
  {
6003
+ settings: controlCallSettings(captainAgent!),
5889
6004
  visibility: 'hidden',
5890
6005
  resume: false,
5891
6006
  ...controlCallToolOptions(captainAdapter),
@@ -6043,12 +6158,16 @@ export function createPlaybookCaptainShell(
6043
6158
  enablement,
6044
6159
  hostCapabilitiesById,
6045
6160
  );
6161
+ // A nested child inherits only its parent's selection, never the turn's
6162
+ // unselected references; only a root reads the turn's selection.
6163
+ const inherited = parent ? parent.frame.attachments : (activeTurn?.selectedAttachments ?? activeTurn?.attachments);
6046
6164
  return {
6047
6165
  entry,
6048
6166
  enablement,
6049
6167
  runtime,
6050
6168
  sessionId,
6051
6169
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
6170
+ ...(inherited?.length ? { attachments: [...inherited] } : {}),
6052
6171
  depth: parent ? parent.frame.depth + 1 : 0,
6053
6172
  playerBindings,
6054
6173
  ...(parent ? { parent } : {}),
@@ -6072,12 +6191,14 @@ export function createPlaybookCaptainShell(
6072
6191
  enablement,
6073
6192
  runtime,
6074
6193
  sessionId: snapshot.sessionId,
6194
+ effectOwnerSessionId: snapshot.runtime.retainedEffectSourceSessionId ?? snapshot.sessionId,
6075
6195
  rootSessionId: snapshot.rootSessionId,
6076
6196
  depth: snapshot.depth,
6077
6197
  playerBindings,
6078
6198
  ...(parent ? { parent } : {}),
6079
6199
  ...(snapshot.request === undefined ? {} : { request: snapshot.request }),
6080
6200
  ...(snapshot.inputs === undefined ? {} : { inputs: [...snapshot.inputs] }),
6201
+ ...(snapshot.attachments === undefined ? {} : { attachments: [...snapshot.attachments] }),
6081
6202
  state: snapshot.runtime.state,
6082
6203
  inFlightHostCalls: new Set(),
6083
6204
  };
@@ -6827,6 +6948,23 @@ export function createPlaybookCaptainShell(
6827
6948
  return;
6828
6949
  }
6829
6950
  assertRetainableResult(frame, result);
6951
+ // A deferred checkpoint refusal changes the runtime's control overlay
6952
+ // without publishing an FSM transition. Refresh its derived question
6953
+ // mirror from an exact safe snapshot before reporting or saving the stop.
6954
+ if (leafFrame() === frame && !frame.disposing &&
6955
+ retainedEffectReconciliation === undefined) {
6956
+ try {
6957
+ const exported = frame.runtime.exportSnapshot?.();
6958
+ if (exported !== undefined) {
6959
+ const snapshot = assertPlaybookRuntimeSnapshot(exported, frame.entry.id);
6960
+ if (isDeepStrictEqual(snapshot.state, result.state) &&
6961
+ isDeepStrictEqual(snapshot.effectLedger, currentEffectLedger())) {
6962
+ pendingBossQuestions = snapshot.pendingBossQuestions.length === 0 && pendingBossQuestions === undefined
6963
+ ? undefined : mirroredBossQuestions(snapshot.pendingBossQuestions);
6964
+ }
6965
+ }
6966
+ } catch { /* An inconsistent runtime must still fail exact capture. */ }
6967
+ }
6830
6968
  if (result.outcome === 'aborted' && runFailureFacts) {
6831
6969
  runFailureFacts.push(
6832
6970
  `${frameLabel(frame)} was aborted before its outcome could be confirmed; it was not repeated automatically.`,
@@ -6908,6 +7046,9 @@ export function createPlaybookCaptainShell(
6908
7046
  );
6909
7047
  }
6910
7048
 
7049
+ const reviewRefusal = missingPackagedReview(enablementById, entry.id);
7050
+ if (reviewRefusal !== undefined) throw new Error(reviewRefusal);
7051
+
6911
7052
  pendingChildParents.add(parent);
6912
7053
  let child: EngagementFrame;
6913
7054
  try {
@@ -7899,11 +8040,11 @@ export function createPlaybookCaptainShell(
7899
8040
  context.signal.throwIfAborted();
7900
8041
  attempt.providerBoundaryEntered = true;
7901
8042
  const result = await classifySettingsCall(() =>
7902
- context.callCaptain(prompt, {
8043
+ context.callCaptain(pendingAttachments.length || activeTurn?.attachments.length || activeTurn?.evidence?.length || leafFrame()?.attachments?.length ? `${prompt}\n\nAttachment context (metadata only; do not claim to have viewed these bytes): ${JSON.stringify({ current: activeTurn?.attachments ?? [], pending: pendingAttachments, engagement: leafFrame()?.attachments ?? [], evidence: activeTurn?.evidence ?? [] })}` : prompt, {
7903
8044
  visibility: 'hidden',
7904
8045
  resume,
7905
8046
  ...controlCallToolOptions(captainAdapter),
7906
- settings: callSettings(captainAgent!),
8047
+ settings: controlCallSettings(captainAgent!),
7907
8048
  }),
7908
8049
  );
7909
8050
  context.signal.throwIfAborted();
@@ -8122,6 +8263,9 @@ export function createPlaybookCaptainShell(
8122
8263
  ...(kind === 'decision'
8123
8264
  ? [labeledBlock('Catalog digest', catalogDigest())]
8124
8265
  : []),
8266
+ ...(kind === 'closingReply' && turn?.workerReports.size
8267
+ ? [workerReportBlock([...turn.workerReports.values()])!]
8268
+ : []),
8125
8269
  ...(kind === 'closingReply' && turn?.report
8126
8270
  ? [
8127
8271
  labeledBlock('ControlView digest', controlViewDigest()),
@@ -8506,6 +8650,7 @@ export function createPlaybookCaptainShell(
8506
8650
  enablement,
8507
8651
  runtime: offer.runtimes[index]!,
8508
8652
  sessionId: targetSessionIds[index]!,
8653
+ effectOwnerSessionId: sourceFrame.runtime.retainedEffectSourceSessionId ?? sourceFrame.sessionId,
8509
8654
  rootSessionId: targetRootSessionId,
8510
8655
  depth: index,
8511
8656
  playerBindings: makePlayerBindings(enablement),
@@ -8514,6 +8659,7 @@ export function createPlaybookCaptainShell(
8514
8659
  : {}),
8515
8660
  ...(sourceFrame.request === undefined ? {} : { request: sourceFrame.request }),
8516
8661
  ...(sourceFrame.inputs === undefined ? {} : { inputs: [...sourceFrame.inputs] }),
8662
+ ...(sourceFrame.attachments === undefined ? {} : { attachments: [...sourceFrame.attachments] }),
8517
8663
  state: sourceFrame.runtime.state,
8518
8664
  inFlightHostCalls: new Set(),
8519
8665
  });
@@ -8896,7 +9042,13 @@ export function createPlaybookCaptainShell(
8896
9042
  // its decision-call prose crosses the presentation boundary. Acting
8897
9043
  // selections freeze after their work and before reporting begins.
8898
9044
  if (selection.action === 'respond') freezeControllerEvidence();
8899
- let settlement = await executeSelection(selection, signal, automaticRecovery);
9045
+ signal.throwIfAborted();
9046
+ const reviewRefusal = selection.action === 'start' || selection.action === 'switch'
9047
+ ? missingPackagedReview(enablementById, selection.playbookId)
9048
+ : undefined;
9049
+ let settlement = reviewRefusal === undefined
9050
+ ? await executeSelection(selection, signal, automaticRecovery)
9051
+ : await rejectSelection(selection, reviewRefusal);
8900
9052
  // One controller action can include bounded prerequisite recovery.
8901
9053
  // Neither the model nor a retry manufactures an unadvertised transition.
8902
9054
  if (turn && abortPreparation && settlement.status !== 'rejected' && !['respond', 'dismiss', 'recover'].includes(selection.action)) {
@@ -9177,6 +9329,11 @@ export function createPlaybookCaptainShell(
9177
9329
  );
9178
9330
  }
9179
9331
  }
9332
+ const selectedIds = selection.attachmentIds;
9333
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leafFrame()?.attachments ?? []).map((reference) => [reference.assetId, reference]));
9334
+ if (selectedIds?.some((id) => !available.has(id as SessionAssetRef['assetId'])) || (selectedIds && new Set(selectedIds).size !== selectedIds.length)) return rejectSelection(selection, 'the request selects unavailable attachment evidence');
9335
+ turn.selectedAttachments = selectedIds === undefined ? turn.attachments : selectedIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9336
+ pendingAttachments = [];
9180
9337
  turn.settled = true;
9181
9338
  journalAction({
9182
9339
  action: selection.action,
@@ -9324,7 +9481,7 @@ export function createPlaybookCaptainShell(
9324
9481
  `Later Boss input (context only): ${JSON.stringify(laterInputs)}`,
9325
9482
  `Existing instructions: ${JSON.stringify(instructions)}`,
9326
9483
  `Pending questions (quoted evidence): ${JSON.stringify(before.pendingQuestions.map(({ question }) => question))}`,
9327
- ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: callSettings(captainAgent!) }, signal);
9484
+ ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: controlCallSettings(captainAgent!) }, signal);
9328
9485
  const answer = result.status === 'ok' ? controlReplyJson(result.finalText) : undefined;
9329
9486
  const index = answer && typeof answer === 'object' && !Array.isArray(answer) && Object.keys(answer).join(',') === 'instructionIndex'
9330
9487
  ? (answer as { instructionIndex: unknown }).instructionIndex : undefined;
@@ -9383,8 +9540,9 @@ export function createPlaybookCaptainShell(
9383
9540
  claim = await capability.acquire({ signal: preparationSignal }) as NonNullable<typeof claim>;
9384
9541
  await claim.assertOwner();
9385
9542
  const result = await runEffect(() => callCaptainQueued(
9386
- leaf, context, preparationPrompt,
9387
- { visibility: 'hidden', resume: false, settings: callSettings(captainAgent!) }, preparationSignal,
9543
+ leaf, context, preparationPrompt,
9544
+ { visibility: 'hidden', resume: false, settings: callSettings(captainAgent!) }, preparationSignal,
9545
+ { attachments: leaf.attachments, preparation: true },
9388
9546
  ));
9389
9547
  preparationSignal.throwIfAborted();
9390
9548
  await claim.assertOwner();
@@ -9429,6 +9587,14 @@ export function createPlaybookCaptainShell(
9429
9587
  }
9430
9588
 
9431
9589
  if (selection.action === 'deliver') {
9590
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leaf.attachments ?? []).map((reference) => [reference.assetId, reference]));
9591
+ if (selection.attachmentIds?.some((id) => !available.has(id as SessionAssetRef['assetId'])) || (selection.attachmentIds && new Set(selection.attachmentIds).size !== selection.attachmentIds.length)) return rejectSelection(selection, 'the request selects unavailable attachment evidence');
9592
+ const selected = selection.attachmentIds === undefined ? turn.attachments : selection.attachmentIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9593
+ const nextAttachments = mergeAttachments(leaf.attachments ?? [], selected);
9594
+ if (nextAttachments.length) leaf.attachments = nextAttachments;
9595
+ // Delivered references now belong to the leaf; the rest stay pending.
9596
+ const delivered = new Set(selected.map((reference) => reference.assetId));
9597
+ pendingAttachments = pendingAttachments.filter((reference) => !delivered.has(reference.assetId));
9432
9598
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
9433
9599
  // for that text — any text carried on the selection is ignored.
9434
9600
  turn.settled = true;
@@ -10077,6 +10243,7 @@ export function createPlaybookCaptainShell(
10077
10243
  : {}),
10078
10244
  ...(frame.request === undefined ? {} : { request: frame.request }),
10079
10245
  ...(frame.inputs === undefined ? {} : { inputs: [...frame.inputs] }),
10246
+ ...(frame.attachments === undefined ? {} : { attachments: [...frame.attachments] }),
10080
10247
  options: frame.enablement.options,
10081
10248
  roleBindings: Object.fromEntries(
10082
10249
  [...frame.playerBindings].map(([role, binding]) => [
@@ -10345,6 +10512,7 @@ export function createPlaybookCaptainShell(
10345
10512
  issuedSessionIds: [...issuedSessionIds],
10346
10513
  sequences: { turn: turnSequence, journal: journalSeq },
10347
10514
  journal,
10515
+ ...(pendingAttachments.length ? { pendingAttachments } : {}),
10348
10516
  ...(lastAction === undefined ? {} : { lastAction }),
10349
10517
  ...(lastSettlementStatus === undefined
10350
10518
  ? {}
@@ -10683,6 +10851,7 @@ export function createPlaybookCaptainShell(
10683
10851
  }
10684
10852
  for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
10685
10853
  journal.push(...snapshot.journal);
10854
+ pendingAttachments = [...(snapshot.pendingAttachments ?? [])];
10686
10855
  journalSeq = snapshot.sequences.journal;
10687
10856
  turnSequence = snapshot.sequences.turn;
10688
10857
  conversation = snapshot.captain.conversation;
@@ -10841,10 +11010,29 @@ export function createPlaybookCaptainShell(
10841
11010
  submitShellAction: selectShellAction,
10842
11011
  selectInterruptedReport(input, report) {
10843
11012
  if (activeTurn) throw new Error('A Captain turn is already active');
10844
- if (!input.trim() || !report.text.trim()) throw new Error('Interruption report requires its recorded input and explanation');
11013
+ if ((!input.trim() && nextTurnAttachments.length === 0) || !report.text.trim()) throw new Error('Interruption report requires its recorded input and explanation');
10845
11014
  pendingHostSelection = { kind: 'report', text: input, report };
10846
11015
  },
10847
11016
 
11017
+ recordMediaEvidence(reference, runtimeSessionId) {
11018
+ const asset = validateAssetRef(reference);
11019
+ if (!activeTurn) throw new Error('Media evidence arrived outside an active turn');
11020
+ activeTurn.evidence = mergeAttachments(activeTurn.evidence ?? [], [asset]);
11021
+ const producingIndex = frames.findIndex((candidate) => candidate.sessionId === runtimeSessionId);
11022
+ // Observed output belongs to this engagement's reasoning context: a
11023
+ // resumed parent may inspect its child's figure. Boss input additions
11024
+ // still target only the addressed leaf, and a new root inherits none.
11025
+ for (const frame of frames.slice(0, producingIndex + 1)) {
11026
+ frame.attachments = mergeAttachments(frame.attachments ?? [], [asset]);
11027
+ }
11028
+ },
11029
+
11030
+ setTurnAttachments(references) {
11031
+ if (activeTurn || activeTurnHostCalls) throw new Error('A Captain turn is already active');
11032
+ nextTurnAttachments = validateAssetRefs(references);
11033
+ onTurnAttachments?.(nextTurnAttachments);
11034
+ },
11035
+
10848
11036
  async handleBossTurn(
10849
11037
  turn: BossTurn,
10850
11038
  context: CaptainContext,
@@ -10870,7 +11058,9 @@ export function createPlaybookCaptainShell(
10870
11058
  // telemetry (CAPTAIN-7).
10871
11059
  retentionSettlementReady = false;
10872
11060
  abandonmentSettlementUnsafe = false;
10873
- if (turn.prompt.trim().length === 0) return;
11061
+ const attachments = nextTurnAttachments;
11062
+ nextTurnAttachments = [];
11063
+ if (turn.prompt.trim().length === 0 && attachments.length === 0) return;
10874
11064
  const progressBase = exportShellSnapshot();
10875
11065
  settledTurnUnresolvedEffects = undefined;
10876
11066
  retainedGenerationInstallationClosed = true;
@@ -10900,6 +11090,10 @@ export function createPlaybookCaptainShell(
10900
11090
  const parsed =
10901
11091
  decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
10902
11092
  activeTurn = {
11093
+ attachments,
11094
+ workerReports: new Map(),
11095
+ workerStepStarts: new Map(),
11096
+ workerCalls: new Map(),
10903
11097
  appliedActionCount: 0,
10904
11098
  stoppedFrames: new Set<string>(),
10905
11099
  ...(progressBase === undefined ? {} : { progressBase }),
@@ -10932,7 +11126,8 @@ export function createPlaybookCaptainShell(
10932
11126
  }
10933
11127
  }
10934
11128
  decisionCall = undefined;
10935
- appendJournal('boss', turn.prompt);
11129
+ appendJournal('boss', attachments.length ? { text: turn.prompt, attachments } as unknown as JsonValue : turn.prompt);
11130
+ pendingAttachments = mergeAttachments(pendingAttachments, attachments);
10936
11131
  try {
10937
11132
  await drainRetiredRetainedRuntimes();
10938
11133
  await prepareRetainedGenerationOffers();
@@ -10941,6 +11136,11 @@ export function createPlaybookCaptainShell(
10941
11136
  await settleSelection({ action: 'respond', text: activeTurn.interruptedReport.text }, context.signal);
10942
11137
  return;
10943
11138
  }
11139
+ if (!turn.prompt.trim() && attachments.length) {
11140
+ markConversationCatchUp();
11141
+ await settleSelection({ action: 'respond', text: 'What would you like me to do with the attached material?' }, context.signal);
11142
+ return;
11143
+ }
10944
11144
  const result = await captainRuntime.handleBossInput({
10945
11145
  text: turn.prompt,
10946
11146
  signal: context.signal,