@sublang/playbook 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -2
  2. package/docs/cli.md +6 -5
  3. package/docs/configuration.md +72 -18
  4. package/docs/embedding.md +94 -0
  5. package/package.json +34 -5
  6. package/reference/sdlc/captain.md +6 -4
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
  10. package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
  14. package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
  15. package/reference/sdlc/code.playbook/bin/launch-config.js +84 -31
  16. package/reference/sdlc/code.playbook/bin/playbook.js +5 -3
  17. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  18. package/reference/sdlc/code.playbook/bin/run.js +74 -7
  19. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  20. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  21. package/reference/sdlc/code.playbook/bin/session-store.js +164 -25
  22. package/reference/sdlc/code.playbook/playbook-captain.d.ts +23 -4
  23. package/reference/sdlc/code.playbook/playbook-captain.js +188 -23
  24. package/reference/sdlc/code.playbook/playbook-captain.ts +223 -31
  25. package/reference/sdlc/code.playbook/playbook.config.template.yaml +22 -5
  26. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  27. package/reference/sdlc/code.playbook/session-assets.js +302 -0
  28. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  29. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  30. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  31. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  32. package/reference/sdlc/inspect.md +40 -0
  33. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  34. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  35. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  36. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  37. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  38. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  39. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  40. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  41. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  42. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  43. package/slc/workflow-contracts.json +43 -1
@@ -61,23 +61,37 @@ 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>;
69
75
  /** Adapter-scoped fast mode. Absence is the provider default; `false` is a
70
76
  * literal request, so this carries no provider-default sentinel. */
71
77
  readonly fastMode?: boolean;
72
- /** The model every subagent of this agent runs on (DR-075). Absence is the
73
- * provider default. */
78
+ /** The model every subagent of this agent runs on (DR-075), or `inherit`
79
+ * for the agent's own model (DR-076). Absence is the provider default. */
74
80
  readonly subagentModel?: string;
81
+ /** The effort every subagent of this agent runs at (DR-076). Absence leaves
82
+ * each subagent's effort to the agent. */
83
+ readonly subagentEffort?: string;
75
84
  readonly instruction?: string;
76
85
  readonly permissions?: PermissionPolicy;
77
86
  }
78
87
 
79
88
  /** Per-call tuning: erased from every structural envelope and snapshot. */
80
- type TuningKey = 'model' | 'effort' | 'fastMode' | 'subagentModel';
89
+ type TuningKey =
90
+ | 'model'
91
+ | 'effort'
92
+ | 'fastMode'
93
+ | 'subagentModel'
94
+ | 'subagentEffort';
81
95
 
82
96
  interface PlayerLedgerEntry {
83
97
  readonly adapter: string;
@@ -113,13 +127,13 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
113
127
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
114
128
  }
115
129
 
116
- type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey>>;
130
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey | 'browser'>>;
117
131
 
118
132
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
119
133
 
120
134
  export interface ProgressChange {
121
135
  snapshot?: PlaybookCaptainShellSnapshot | null;
122
- 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 } };
123
137
  }
124
138
  export interface InterruptedReport {
125
139
  text: string;
@@ -129,6 +143,9 @@ export interface InterruptedReport {
129
143
  }
130
144
 
131
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[]>;
132
149
  /** Stop the host's active turn, including admitted tool calls, on preparation expiry or a required save failure. */
133
150
  abortPreparation?: (reason?: string) => void;
134
151
  recordProgress?: (change: ProgressChange) => Promise<void>;
@@ -251,6 +268,7 @@ export interface PlaybookCaptainFrameSnapshot {
251
268
  readonly parentCallId?: string;
252
269
  readonly request?: string;
253
270
  readonly inputs?: readonly string[];
271
+ readonly attachments?: readonly SessionAssetRef[];
254
272
  readonly options: JsonValue;
255
273
  readonly roleBindings: Readonly<Record<string, string>>;
256
274
  readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
@@ -274,6 +292,7 @@ interface PlaybookCaptainShellSnapshotFields {
274
292
  readonly journal: number;
275
293
  };
276
294
  readonly journal: readonly PlaybookCaptainJournalRecord[];
295
+ readonly pendingAttachments?: readonly SessionAssetRef[];
277
296
  readonly lastAction?:
278
297
  | 'respond'
279
298
  | 'start'
@@ -380,6 +399,9 @@ export interface PlaybookCaptainSettlement {
380
399
 
381
400
  /** tmux and headless front ends share this one durable Captain shell API. */
382
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;
383
405
  installRetainedGenerations(
384
406
  generations: Readonly<
385
407
  Record<string, PlaybookCaptainRetainedGeneration>
@@ -452,6 +474,7 @@ interface EffectivePlayerBinding {
452
474
  readonly effort: TuningSelection<Effort>;
453
475
  readonly fastMode?: boolean;
454
476
  readonly subagentModel?: string;
477
+ readonly subagentEffort?: string;
455
478
  readonly agent: SessionAgent;
456
479
  }
457
480
 
@@ -479,6 +502,8 @@ type PlayerTransaction =
479
502
  });
480
503
 
481
504
  interface EngagementFrame {
505
+ effectOwnerSessionId?: string;
506
+ attachments?: readonly SessionAssetRef[];
482
507
  request?: string;
483
508
  inputs?: string[];
484
509
  pauseCancellation?: () => () => void;
@@ -668,6 +693,12 @@ interface ActiveTurnSummary {
668
693
 
669
694
  /** The shell state of one Boss turn (DR-029). */
670
695
  interface ActiveTurn {
696
+ readonly workerReports: Map<string, string>;
697
+ readonly workerStepStarts: Map<string, ReadonlySet<string>>;
698
+ readonly workerCalls: Map<string, { frame: EngagementFrame; callId: string; roleId: string; playerId: string; stateId: string; finalText: string }>;
699
+ evidence?: readonly SessionAssetRef[];
700
+ attachments: readonly SessionAssetRef[];
701
+ selectedAttachments?: readonly SessionAssetRef[];
671
702
  appliedActionCount: number;
672
703
  startedEngagement?: boolean;
673
704
  countedSummary?: ActiveTurnSummary;
@@ -838,7 +869,6 @@ function quoteEvidence(text: string): string {
838
869
  // into a fact and still knows it is foreign — never at the digest renderer,
839
870
  // which sees an opaque payload and cannot tell quoted output from the Boss
840
871
  // text, captain speech, or settlement facts the shell authored itself.
841
- const QUOTED_EVIDENCE_LIMIT = 400;
842
872
 
843
873
  // The same guard for strings the shell interpolates into a single-line fact:
844
874
  // control characters collapse to spaces so a quoted message can never open a
@@ -3002,6 +3032,7 @@ export function assertPlaybookCaptainShellSnapshot(
3002
3032
  'issuedSessionIds',
3003
3033
  'sequences',
3004
3034
  'journal',
3035
+ 'pendingAttachments',
3005
3036
  'lastAction',
3006
3037
  'lastSettlementStatus',
3007
3038
  'mode',
@@ -3330,6 +3361,7 @@ export function assertPlaybookCaptainShellSnapshot(
3330
3361
  issuedSessionIds: issued,
3331
3362
  sequences: { turn: turnSequence, journal: journalSequence },
3332
3363
  journal: normalizedJournal,
3364
+ ...(snapshot.pendingAttachments === undefined ? {} : { pendingAttachments: validateAssetRefs(snapshot.pendingAttachments) }),
3333
3365
  ...(lastAction === undefined ? {} : { lastAction }),
3334
3366
  ...(lastSettlementStatus === undefined
3335
3367
  ? {}
@@ -3381,6 +3413,7 @@ export function assertPlaybookCaptainShellSnapshot(
3381
3413
  'parentCallId',
3382
3414
  'request',
3383
3415
  'inputs',
3416
+ 'attachments',
3384
3417
  'options',
3385
3418
  'roleBindings',
3386
3419
  'runtime',
@@ -3438,6 +3471,7 @@ export function assertPlaybookCaptainShellSnapshot(
3438
3471
  ...(parentCallId === undefined ? {} : { parentCallId }),
3439
3472
  ...(frame.request === undefined ? {} : { request: frame.request as string }),
3440
3473
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs as string[] }),
3474
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
3441
3475
  options,
3442
3476
  roleBindings,
3443
3477
  runtime,
@@ -3727,7 +3761,7 @@ function snapshotFastMode(
3727
3761
  return value;
3728
3762
  }
3729
3763
 
3730
- function snapshotSubagentModel(
3764
+ function snapshotNonblankTuning(
3731
3765
  value: JsonValue | undefined,
3732
3766
  path: string,
3733
3767
  ): string | undefined {
@@ -3753,6 +3787,8 @@ function snapshotSessionAgent(
3753
3787
  'effort',
3754
3788
  'fastMode',
3755
3789
  'subagentModel',
3790
+ 'subagentEffort',
3791
+ 'browser',
3756
3792
  'instruction',
3757
3793
  'permissions',
3758
3794
  ],
@@ -3765,7 +3801,9 @@ function snapshotSessionAgent(
3765
3801
  key !== 'model' &&
3766
3802
  key !== 'effort' &&
3767
3803
  key !== 'fastMode' &&
3768
- key !== 'subagentModel',
3804
+ key !== 'subagentModel' &&
3805
+ key !== 'subagentEffort' &&
3806
+ key !== 'browser',
3769
3807
  ),
3770
3808
  ) as JsonValue,
3771
3809
  path,
@@ -3778,6 +3816,7 @@ function snapshotSessionAgent(
3778
3816
  ...(fixed.permissions === undefined
3779
3817
  ? {}
3780
3818
  : { permissions: livePermissions(fixed.permissions) }),
3819
+ ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
3781
3820
  model: snapshotTuningSelection(agent.model, `${path}.model`),
3782
3821
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
3783
3822
  ...(agent.fastMode === undefined
@@ -3786,15 +3825,23 @@ function snapshotSessionAgent(
3786
3825
  ...(agent.subagentModel === undefined
3787
3826
  ? {}
3788
3827
  : {
3789
- subagentModel: snapshotSubagentModel(
3828
+ subagentModel: snapshotNonblankTuning(
3790
3829
  agent.subagentModel,
3791
3830
  `${path}.subagentModel`,
3792
3831
  ),
3793
3832
  }),
3833
+ ...(agent.subagentEffort === undefined
3834
+ ? {}
3835
+ : {
3836
+ subagentEffort: snapshotNonblankTuning(
3837
+ agent.subagentEffort,
3838
+ `${path}.subagentEffort`,
3839
+ ),
3840
+ }),
3794
3841
  };
3795
3842
  }
3796
3843
 
3797
- function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey> {
3844
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey | 'browser'> {
3798
3845
  return {
3799
3846
  adapter: agent.adapter,
3800
3847
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
@@ -3808,19 +3855,29 @@ function callSettings(
3808
3855
  ): AgentCallSettings {
3809
3856
  // cligent treats supplied call settings as a complete replacement, so an
3810
3857
  // omitted fastMode or subagentModel here is a request for the provider
3811
- // default, never an inheritance of whatever the previous call left behind.
3858
+ // default, and an omitted subagentEffort leaves each subagent's effort to
3859
+ // the agent — never an inheritance of whatever the previous call left
3860
+ // behind.
3812
3861
  return {
3813
3862
  model: tuning.model,
3814
3863
  effort: tuning.effort,
3864
+ browser: agent.browser ?? false,
3815
3865
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
3816
3866
  ...(tuning.subagentModel === undefined
3817
3867
  ? {}
3818
3868
  : { subagentModel: tuning.subagentModel }),
3869
+ ...(tuning.subagentEffort === undefined
3870
+ ? {}
3871
+ : { subagentEffort: tuning.subagentEffort as Effort }),
3819
3872
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
3820
3873
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
3821
3874
  };
3822
3875
  }
3823
3876
 
3877
+ function controlCallSettings(agent: SessionAgent): AgentCallSettings {
3878
+ return { ...callSettings(agent), browser: false, mcpServers: {} };
3879
+ }
3880
+
3824
3881
  function promptIdentity(binding: EffectivePlayerBinding): string {
3825
3882
  return binding.model.kind === 'value'
3826
3883
  ? binding.model.value
@@ -4043,7 +4100,14 @@ async function buildEnablements(
4043
4100
  const rawBinding = snapshotRecord(roleRecord[role], path);
4044
4101
  rejectSnapshotKeys(
4045
4102
  rawBinding,
4046
- ['playerId', 'model', 'effort', 'fastMode', 'subagentModel'],
4103
+ [
4104
+ 'playerId',
4105
+ 'model',
4106
+ 'effort',
4107
+ 'fastMode',
4108
+ 'subagentModel',
4109
+ 'subagentEffort',
4110
+ ],
4047
4111
  path,
4048
4112
  );
4049
4113
  const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
@@ -4071,11 +4135,19 @@ async function buildEnablements(
4071
4135
  ...(rawBinding.subagentModel === undefined
4072
4136
  ? {}
4073
4137
  : {
4074
- subagentModel: snapshotSubagentModel(
4138
+ subagentModel: snapshotNonblankTuning(
4075
4139
  rawBinding.subagentModel,
4076
4140
  `${path}.subagentModel`,
4077
4141
  ),
4078
4142
  }),
4143
+ ...(rawBinding.subagentEffort === undefined
4144
+ ? {}
4145
+ : {
4146
+ subagentEffort: snapshotNonblankTuning(
4147
+ rawBinding.subagentEffort,
4148
+ `${path}.subagentEffort`,
4149
+ ),
4150
+ }),
4079
4151
  agent,
4080
4152
  });
4081
4153
  }
@@ -4382,6 +4454,17 @@ export function createPlaybookCaptainShell(
4382
4454
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
4383
4455
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
4384
4456
  const continuity = deps.continuity;
4457
+ const resolveAttachments = deps.resolveAttachments;
4458
+ const beginPreparationMedia = deps.beginPreparationMedia;
4459
+ const onTurnAttachments = deps.onTurnAttachments;
4460
+ let nextTurnAttachments: readonly SessionAssetRef[] = [];
4461
+ let pendingAttachments: readonly SessionAssetRef[] = [];
4462
+ const mergeAttachments = (...groups: readonly (readonly SessionAssetRef[])[]) => [...new Map(groups.flat().map((reference) => [reference.assetId, reference])).values()];
4463
+ const attachmentPaths = async (references: readonly SessionAssetRef[] | undefined, signal: AbortSignal) => {
4464
+ if (!references?.length) return undefined;
4465
+ if (!resolveAttachments) throw new Error('The session host cannot materialize required attachments');
4466
+ return resolveAttachments(references, signal);
4467
+ };
4385
4468
  const abortPreparation = deps.abortPreparation;
4386
4469
  let pendingHostCapabilities = deps.hostCapabilities;
4387
4470
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
@@ -4840,6 +4923,7 @@ export function createPlaybookCaptainShell(
4840
4923
  'parentCallId',
4841
4924
  'request',
4842
4925
  'inputs',
4926
+ 'attachments',
4843
4927
  'options',
4844
4928
  'roleBindings',
4845
4929
  'runtime',
@@ -4989,6 +5073,7 @@ export function createPlaybookCaptainShell(
4989
5073
  ...(parentCallId === undefined ? {} : { parentCallId }),
4990
5074
  ...(frame.request === undefined ? {} : { request: frame.request as string }),
4991
5075
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs as string[] }),
5076
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
4992
5077
  options,
4993
5078
  roleBindings,
4994
5079
  runtime,
@@ -5488,15 +5573,21 @@ export function createPlaybookCaptainShell(
5488
5573
  prompt: string,
5489
5574
  options: Parameters<CaptainContext['callCaptain']>[1],
5490
5575
  signal: AbortSignal,
5576
+ media?: { readonly attachments?: readonly SessionAssetRef[]; readonly preparation?: true },
5491
5577
  ): ReturnType<CaptainContext['callCaptain']> => {
5492
5578
  const queued = captainQueue.add(async () => {
5493
5579
  signal.throwIfAborted();
5494
- const result = await trackHostCall(
5495
- frame,
5496
- context.callCaptain(prompt, options),
5497
- );
5580
+ const attachments = media?.attachments?.length ? await attachmentPaths(media.attachments, signal) : undefined;
5498
5581
  signal.throwIfAborted();
5499
- return result;
5582
+ const endMedia = media?.preparation ? beginPreparationMedia?.(frame.sessionId) : undefined;
5583
+ try {
5584
+ const result = await trackHostCall(
5585
+ frame,
5586
+ context.callCaptain(prompt, { ...options, ...(attachments === undefined ? {} : { attachments }) }),
5587
+ );
5588
+ signal.throwIfAborted();
5589
+ return result;
5590
+ } finally { endMedia?.(); }
5500
5591
  });
5501
5592
  return trackHostCall(frame, queued);
5502
5593
  };
@@ -5582,12 +5673,20 @@ export function createPlaybookCaptainShell(
5582
5673
  !Number.isSafeInteger(trace.turnId) ||
5583
5674
  (trace.turnId as number) <= 0 ||
5584
5675
  !Number.isSafeInteger(trace.sequence) ||
5585
- (trace.sequence as number) <= 0 ||
5586
- summary === undefined ||
5587
- !summaryIncludes(frame)
5676
+ (trace.sequence as number) <= 0
5588
5677
  ) {
5589
5678
  return;
5590
5679
  }
5680
+ if (trace.type === 'player.call.finished') {
5681
+ const call = payloadRecord(trace.payload);
5682
+ if (typeof trace.callId === 'string' && call?.status === 'ok' &&
5683
+ typeof call.roleId === 'string' && typeof call.playerId === 'string' &&
5684
+ frame.playerBindings.get(call.roleId)?.playerId === call.playerId &&
5685
+ typeof call.stateId === 'string' && typeof call.finalText === 'string') {
5686
+ turn.workerCalls.set(`${frame.sessionId}:${trace.callId}`, { frame, callId: trace.callId, roleId: call.roleId, playerId: call.playerId, stateId: call.stateId, finalText: call.finalText });
5687
+ }
5688
+ return;
5689
+ }
5591
5690
  if (trace.type !== 'outcome.accepted') return;
5592
5691
  const receipt = exactOwnDataRecord(trace.payload, [
5593
5692
  'source',
@@ -5605,6 +5704,21 @@ export function createPlaybookCaptainShell(
5605
5704
  ) {
5606
5705
  return;
5607
5706
  }
5707
+ const ledger = currentEffectLedger();
5708
+ const previous = turn.progressBase?.effectLedger;
5709
+ for (const call of turn.workerCalls.values()) {
5710
+ if (call.frame !== frame || call.stateId !== receipt.source) continue;
5711
+ const boundary = [...ledger.boundaries].reverse().find((entry) =>
5712
+ entry.playbookId === frame.entry.id &&
5713
+ entry.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
5714
+ entry.callId === call.callId && entry.roleId === call.roleId &&
5715
+ entry.sourceStateId === call.stateId && entry.finalText === call.finalText &&
5716
+ !isDeepStrictEqual(entry, previous?.boundaries[entry.sequence - 1]));
5717
+ if (!boundary || turn.workerReports.has(boundary.boundaryId)) continue;
5718
+ const report = workerReportExcerpt(boundary, call.playerId, receipt.acceptedOutcome);
5719
+ if (report !== undefined) turn.workerReports.set(boundary.boundaryId, report);
5720
+ }
5721
+ if (summary === undefined || !summaryIncludes(frame)) return;
5608
5722
  const traceKey = `${frame.sessionId}:${trace.sequence}`;
5609
5723
  if (summary.acceptedOutcomeTraceKeys.has(traceKey)) return;
5610
5724
  summary.acceptedOutcomeTraceKeys.add(traceKey);
@@ -5632,8 +5746,28 @@ export function createPlaybookCaptainShell(
5632
5746
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
5633
5747
  ...(recordProgress === undefined ? {} : { recordStep: async (step: PlaybookStepRecord, position?: PlaybookRuntimeSnapshot) => {
5634
5748
  journalledFrames.add(frame);
5749
+ const turn = activeTurn;
5750
+ if (step.kind === 'player' && step.result === undefined) {
5751
+ turn?.workerStepStarts.set(step.id, new Set(turn.workerCalls.keys()));
5752
+ }
5753
+ let workerEvidence: { boundaryId: string; playerId: string } | undefined;
5754
+ const guard = payloadRecord(step.result)?.guard;
5755
+ const before = turn?.workerStepStarts.get(step.id);
5756
+ if (step.kind === 'player' && typeof guard === 'string' && before !== undefined && turn) {
5757
+ const candidates = [...turn.workerCalls.entries()].flatMap(([key, call]) => {
5758
+ if (before.has(key) || call.frame !== frame || call.stateId !== step.stateId) return [];
5759
+ return currentEffectLedger().boundaries.filter((boundary) =>
5760
+ boundary.playbookId === frame.entry.id && boundary.sourceStateId === step.stateId &&
5761
+ boundary.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
5762
+ boundary.callId === call.callId && boundary.roleId === call.roleId && boundary.finalText === call.finalText &&
5763
+ !isDeepStrictEqual(boundary, turn.progressBase?.effectLedger.boundaries[boundary.sequence - 1]) &&
5764
+ workerReportExcerpt(boundary, call.playerId, guard) !== undefined
5765
+ ).map((boundary) => ({ boundaryId: boundary.boundaryId, playerId: call.playerId }));
5766
+ });
5767
+ if (candidates.length === 1) workerEvidence = candidates[0];
5768
+ }
5635
5769
  await saveProgress(step.result === undefined ? { frame, position } : undefined,
5636
- { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id });
5770
+ { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id, ...(workerEvidence === undefined ? {} : { workerEvidence }) });
5637
5771
  } }),
5638
5772
  callPlayer: async (roleId, prompt, signal, options) => {
5639
5773
  admitHostBoundary();
@@ -5666,6 +5800,7 @@ export function createPlaybookCaptainShell(
5666
5800
  );
5667
5801
  }
5668
5802
  const settings = callSettings(binding.agent, binding);
5803
+ const attachments = await attachmentPaths(frame.attachments, signal);
5669
5804
  const calling: PlayerTransaction = {
5670
5805
  phase: 'calling',
5671
5806
  frame,
@@ -5692,7 +5827,7 @@ export function createPlaybookCaptainShell(
5692
5827
  const raw = await trackHostCall(
5693
5828
  frame,
5694
5829
  classifySettingsCall(() =>
5695
- context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings }),
5830
+ context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings, ...(attachments === undefined ? {} : { attachments }) }),
5696
5831
  ),
5697
5832
  );
5698
5833
  hostResolved = true;
@@ -5823,9 +5958,11 @@ export function createPlaybookCaptainShell(
5823
5958
  {
5824
5959
  visibility: options.visibility,
5825
5960
  resume: options.resume,
5961
+ settings: options.allowedTools?.length === 0 ? controlCallSettings(captainAgent!) : callSettings(captainAgent!),
5826
5962
  ...forwardedToolOptions(options.allowedTools, captainAdapter),
5827
5963
  },
5828
5964
  signal,
5965
+ { attachments: options.allowedTools?.length === 0 ? undefined : frame.attachments },
5829
5966
  );
5830
5967
  const output = {
5831
5968
  status: result.status,
@@ -5847,6 +5984,7 @@ export function createPlaybookCaptainShell(
5847
5984
  activeContext,
5848
5985
  hiddenJudgeEnvelope(prompt),
5849
5986
  {
5987
+ settings: controlCallSettings(captainAgent!),
5850
5988
  visibility: 'hidden',
5851
5989
  resume: false,
5852
5990
  ...controlCallToolOptions(captainAdapter),
@@ -6010,6 +6148,7 @@ export function createPlaybookCaptainShell(
6010
6148
  runtime,
6011
6149
  sessionId,
6012
6150
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
6151
+ ...((parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn?.attachments)?.length ? { attachments: [...(parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn!.attachments)] } : {}),
6013
6152
  depth: parent ? parent.frame.depth + 1 : 0,
6014
6153
  playerBindings,
6015
6154
  ...(parent ? { parent } : {}),
@@ -6033,12 +6172,14 @@ export function createPlaybookCaptainShell(
6033
6172
  enablement,
6034
6173
  runtime,
6035
6174
  sessionId: snapshot.sessionId,
6175
+ effectOwnerSessionId: snapshot.runtime.retainedEffectSourceSessionId ?? snapshot.sessionId,
6036
6176
  rootSessionId: snapshot.rootSessionId,
6037
6177
  depth: snapshot.depth,
6038
6178
  playerBindings,
6039
6179
  ...(parent ? { parent } : {}),
6040
6180
  ...(snapshot.request === undefined ? {} : { request: snapshot.request }),
6041
6181
  ...(snapshot.inputs === undefined ? {} : { inputs: [...snapshot.inputs] }),
6182
+ ...(snapshot.attachments === undefined ? {} : { attachments: [...snapshot.attachments] }),
6042
6183
  state: snapshot.runtime.state,
6043
6184
  inFlightHostCalls: new Set(),
6044
6185
  };
@@ -6345,6 +6486,7 @@ export function createPlaybookCaptainShell(
6345
6486
  } finally {
6346
6487
  if (leafFrame() === frame) {
6347
6488
  frames.pop();
6489
+ if (!frame.parent) pendingAttachments = [];
6348
6490
  if (frame.parent) {
6349
6491
  pendingChildParents.delete(frame.parent.frame);
6350
6492
  }
@@ -7860,11 +8002,11 @@ export function createPlaybookCaptainShell(
7860
8002
  context.signal.throwIfAborted();
7861
8003
  attempt.providerBoundaryEntered = true;
7862
8004
  const result = await classifySettingsCall(() =>
7863
- context.callCaptain(prompt, {
8005
+ 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, {
7864
8006
  visibility: 'hidden',
7865
8007
  resume,
7866
8008
  ...controlCallToolOptions(captainAdapter),
7867
- settings: callSettings(captainAgent!),
8009
+ settings: controlCallSettings(captainAgent!),
7868
8010
  }),
7869
8011
  );
7870
8012
  context.signal.throwIfAborted();
@@ -8083,6 +8225,9 @@ export function createPlaybookCaptainShell(
8083
8225
  ...(kind === 'decision'
8084
8226
  ? [labeledBlock('Catalog digest', catalogDigest())]
8085
8227
  : []),
8228
+ ...(kind === 'closingReply' && turn?.workerReports.size
8229
+ ? [workerReportBlock([...turn.workerReports.values()])!]
8230
+ : []),
8086
8231
  ...(kind === 'closingReply' && turn?.report
8087
8232
  ? [
8088
8233
  labeledBlock('ControlView digest', controlViewDigest()),
@@ -8467,6 +8612,7 @@ export function createPlaybookCaptainShell(
8467
8612
  enablement,
8468
8613
  runtime: offer.runtimes[index]!,
8469
8614
  sessionId: targetSessionIds[index]!,
8615
+ effectOwnerSessionId: sourceFrame.runtime.retainedEffectSourceSessionId ?? sourceFrame.sessionId,
8470
8616
  rootSessionId: targetRootSessionId,
8471
8617
  depth: index,
8472
8618
  playerBindings: makePlayerBindings(enablement),
@@ -8475,6 +8621,7 @@ export function createPlaybookCaptainShell(
8475
8621
  : {}),
8476
8622
  ...(sourceFrame.request === undefined ? {} : { request: sourceFrame.request }),
8477
8623
  ...(sourceFrame.inputs === undefined ? {} : { inputs: [...sourceFrame.inputs] }),
8624
+ ...(sourceFrame.attachments === undefined ? {} : { attachments: [...sourceFrame.attachments] }),
8478
8625
  state: sourceFrame.runtime.state,
8479
8626
  inFlightHostCalls: new Set(),
8480
8627
  });
@@ -9138,6 +9285,11 @@ export function createPlaybookCaptainShell(
9138
9285
  );
9139
9286
  }
9140
9287
  }
9288
+ const selectedIds = selection.attachmentIds;
9289
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leafFrame()?.attachments ?? []).map((reference) => [reference.assetId, reference]));
9290
+ 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');
9291
+ turn.selectedAttachments = selectedIds === undefined ? turn.attachments : selectedIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9292
+ pendingAttachments = [];
9141
9293
  turn.settled = true;
9142
9294
  journalAction({
9143
9295
  action: selection.action,
@@ -9285,7 +9437,7 @@ export function createPlaybookCaptainShell(
9285
9437
  `Later Boss input (context only): ${JSON.stringify(laterInputs)}`,
9286
9438
  `Existing instructions: ${JSON.stringify(instructions)}`,
9287
9439
  `Pending questions (quoted evidence): ${JSON.stringify(before.pendingQuestions.map(({ question }) => question))}`,
9288
- ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: callSettings(captainAgent!) }, signal);
9440
+ ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: controlCallSettings(captainAgent!) }, signal);
9289
9441
  const answer = result.status === 'ok' ? controlReplyJson(result.finalText) : undefined;
9290
9442
  const index = answer && typeof answer === 'object' && !Array.isArray(answer) && Object.keys(answer).join(',') === 'instructionIndex'
9291
9443
  ? (answer as { instructionIndex: unknown }).instructionIndex : undefined;
@@ -9344,8 +9496,9 @@ export function createPlaybookCaptainShell(
9344
9496
  claim = await capability.acquire({ signal: preparationSignal }) as NonNullable<typeof claim>;
9345
9497
  await claim.assertOwner();
9346
9498
  const result = await runEffect(() => callCaptainQueued(
9347
- leaf, context, preparationPrompt,
9348
- { visibility: 'hidden', resume: false, settings: callSettings(captainAgent!) }, preparationSignal,
9499
+ leaf, context, preparationPrompt,
9500
+ { visibility: 'hidden', resume: false, settings: callSettings(captainAgent!) }, preparationSignal,
9501
+ { attachments: leaf.attachments, preparation: true },
9349
9502
  ));
9350
9503
  preparationSignal.throwIfAborted();
9351
9504
  await claim.assertOwner();
@@ -9390,6 +9543,11 @@ export function createPlaybookCaptainShell(
9390
9543
  }
9391
9544
 
9392
9545
  if (selection.action === 'deliver') {
9546
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leaf.attachments ?? []).map((reference) => [reference.assetId, reference]));
9547
+ 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');
9548
+ const selected = selection.attachmentIds === undefined ? turn.attachments : selection.attachmentIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9549
+ const nextAttachments = mergeAttachments(leaf.attachments ?? [], selected);
9550
+ if (nextAttachments.length) leaf.attachments = nextAttachments;
9393
9551
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
9394
9552
  // for that text — any text carried on the selection is ignored.
9395
9553
  turn.settled = true;
@@ -10038,6 +10196,7 @@ export function createPlaybookCaptainShell(
10038
10196
  : {}),
10039
10197
  ...(frame.request === undefined ? {} : { request: frame.request }),
10040
10198
  ...(frame.inputs === undefined ? {} : { inputs: [...frame.inputs] }),
10199
+ ...(frame.attachments === undefined ? {} : { attachments: [...frame.attachments] }),
10041
10200
  options: frame.enablement.options,
10042
10201
  roleBindings: Object.fromEntries(
10043
10202
  [...frame.playerBindings].map(([role, binding]) => [
@@ -10306,6 +10465,7 @@ export function createPlaybookCaptainShell(
10306
10465
  issuedSessionIds: [...issuedSessionIds],
10307
10466
  sequences: { turn: turnSequence, journal: journalSeq },
10308
10467
  journal,
10468
+ ...(pendingAttachments.length ? { pendingAttachments } : {}),
10309
10469
  ...(lastAction === undefined ? {} : { lastAction }),
10310
10470
  ...(lastSettlementStatus === undefined
10311
10471
  ? {}
@@ -10644,6 +10804,7 @@ export function createPlaybookCaptainShell(
10644
10804
  }
10645
10805
  for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
10646
10806
  journal.push(...snapshot.journal);
10807
+ pendingAttachments = [...(snapshot.pendingAttachments ?? [])];
10647
10808
  journalSeq = snapshot.sequences.journal;
10648
10809
  turnSequence = snapshot.sequences.turn;
10649
10810
  conversation = snapshot.captain.conversation;
@@ -10802,10 +10963,29 @@ export function createPlaybookCaptainShell(
10802
10963
  submitShellAction: selectShellAction,
10803
10964
  selectInterruptedReport(input, report) {
10804
10965
  if (activeTurn) throw new Error('A Captain turn is already active');
10805
- if (!input.trim() || !report.text.trim()) throw new Error('Interruption report requires its recorded input and explanation');
10966
+ if ((!input.trim() && nextTurnAttachments.length === 0) || !report.text.trim()) throw new Error('Interruption report requires its recorded input and explanation');
10806
10967
  pendingHostSelection = { kind: 'report', text: input, report };
10807
10968
  },
10808
10969
 
10970
+ recordMediaEvidence(reference, runtimeSessionId) {
10971
+ const asset = validateAssetRef(reference);
10972
+ if (!activeTurn) throw new Error('Media evidence arrived outside an active turn');
10973
+ activeTurn.evidence = mergeAttachments(activeTurn.evidence ?? [], [asset]);
10974
+ const producingIndex = frames.findIndex((candidate) => candidate.sessionId === runtimeSessionId);
10975
+ // Observed output belongs to this engagement's reasoning context: a
10976
+ // resumed parent may inspect its child's figure. Boss input additions
10977
+ // still target only the addressed leaf, and a new root inherits none.
10978
+ for (const frame of frames.slice(0, producingIndex + 1)) {
10979
+ frame.attachments = mergeAttachments(frame.attachments ?? [], [asset]);
10980
+ }
10981
+ },
10982
+
10983
+ setTurnAttachments(references) {
10984
+ if (activeTurn || activeTurnHostCalls) throw new Error('A Captain turn is already active');
10985
+ nextTurnAttachments = validateAssetRefs(references);
10986
+ onTurnAttachments?.(nextTurnAttachments);
10987
+ },
10988
+
10809
10989
  async handleBossTurn(
10810
10990
  turn: BossTurn,
10811
10991
  context: CaptainContext,
@@ -10831,7 +11011,9 @@ export function createPlaybookCaptainShell(
10831
11011
  // telemetry (CAPTAIN-7).
10832
11012
  retentionSettlementReady = false;
10833
11013
  abandonmentSettlementUnsafe = false;
10834
- if (turn.prompt.trim().length === 0) return;
11014
+ const attachments = nextTurnAttachments;
11015
+ nextTurnAttachments = [];
11016
+ if (turn.prompt.trim().length === 0 && attachments.length === 0) return;
10835
11017
  const progressBase = exportShellSnapshot();
10836
11018
  settledTurnUnresolvedEffects = undefined;
10837
11019
  retainedGenerationInstallationClosed = true;
@@ -10861,6 +11043,10 @@ export function createPlaybookCaptainShell(
10861
11043
  const parsed =
10862
11044
  decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
10863
11045
  activeTurn = {
11046
+ attachments,
11047
+ workerReports: new Map(),
11048
+ workerStepStarts: new Map(),
11049
+ workerCalls: new Map(),
10864
11050
  appliedActionCount: 0,
10865
11051
  stoppedFrames: new Set<string>(),
10866
11052
  ...(progressBase === undefined ? {} : { progressBase }),
@@ -10893,7 +11079,8 @@ export function createPlaybookCaptainShell(
10893
11079
  }
10894
11080
  }
10895
11081
  decisionCall = undefined;
10896
- appendJournal('boss', turn.prompt);
11082
+ appendJournal('boss', attachments.length ? { text: turn.prompt, attachments } as unknown as JsonValue : turn.prompt);
11083
+ pendingAttachments = mergeAttachments(pendingAttachments, attachments);
10897
11084
  try {
10898
11085
  await drainRetiredRetainedRuntimes();
10899
11086
  await prepareRetainedGenerationOffers();
@@ -10902,6 +11089,11 @@ export function createPlaybookCaptainShell(
10902
11089
  await settleSelection({ action: 'respond', text: activeTurn.interruptedReport.text }, context.signal);
10903
11090
  return;
10904
11091
  }
11092
+ if (!turn.prompt.trim() && attachments.length) {
11093
+ markConversationCatchUp();
11094
+ await settleSelection({ action: 'respond', text: 'What would you like me to do with the attached material?' }, context.signal);
11095
+ return;
11096
+ }
10905
11097
  const result = await captainRuntime.handleBossInput({
10906
11098
  text: turn.prompt,
10907
11099
  signal: context.signal,