@sublang/playbook 17.3.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 (41) 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.playbook/asset-types.d.ts +32 -0
  14. package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
  15. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  16. package/reference/sdlc/code.playbook/bin/run.js +68 -4
  17. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  18. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  19. package/reference/sdlc/code.playbook/bin/session-store.js +139 -13
  20. package/reference/sdlc/code.playbook/playbook-captain.d.ts +17 -1
  21. package/reference/sdlc/code.playbook/playbook-captain.js +159 -18
  22. package/reference/sdlc/code.playbook/playbook-captain.ts +176 -23
  23. package/reference/sdlc/code.playbook/playbook.config.template.yaml +16 -3
  24. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  25. package/reference/sdlc/code.playbook/session-assets.js +302 -0
  26. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  27. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  28. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  29. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  30. package/reference/sdlc/inspect.md +40 -0
  31. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  32. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  33. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  34. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  35. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  36. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  37. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  38. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  39. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  40. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  41. package/slc/workflow-contracts.json +43 -1
@@ -7,6 +7,8 @@ import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
7
7
  import { assertPlaybookFailureCause } from '@sublang/playbook/runtime';
8
8
  import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, emptyPlaybookEffectLedger, hiddenControlEnvelope, isPlaybookEffectLedgerMonotonicExtension, normalizeError, parseJudgeJson, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
9
9
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
10
+ import { validateAssetRef, validateAssetRefs } from './session-assets.js';
11
+ import { QUOTED_EVIDENCE_LIMIT, workerReportExcerpt, workerReportBlock } from './worker-evidence.js';
10
12
  function retainedEffectLedgerCanRebase(checkpoint, current) {
11
13
  if (checkpoint.boundaries.some(({ physicalReceipt }) => physicalReceipt === undefined)) {
12
14
  return false;
@@ -124,7 +126,6 @@ function quoteEvidence(text) {
124
126
  // into a fact and still knows it is foreign — never at the digest renderer,
125
127
  // which sees an opaque payload and cannot tell quoted output from the Boss
126
128
  // text, captain speech, or settlement facts the shell authored itself.
127
- const QUOTED_EVIDENCE_LIMIT = 400;
128
129
  // The same guard for strings the shell interpolates into a single-line fact:
129
130
  // control characters collapse to spaces so a quoted message can never open a
130
131
  // new line — and therefore never a new labeled block — inside a report.
@@ -1685,6 +1686,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1685
1686
  'issuedSessionIds',
1686
1687
  'sequences',
1687
1688
  'journal',
1689
+ 'pendingAttachments',
1688
1690
  'lastAction',
1689
1691
  'lastSettlementStatus',
1690
1692
  'mode',
@@ -1858,6 +1860,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1858
1860
  issuedSessionIds: issued,
1859
1861
  sequences: { turn: turnSequence, journal: journalSequence },
1860
1862
  journal: normalizedJournal,
1863
+ ...(snapshot.pendingAttachments === undefined ? {} : { pendingAttachments: validateAssetRefs(snapshot.pendingAttachments) }),
1861
1864
  ...(lastAction === undefined ? {} : { lastAction }),
1862
1865
  ...(lastSettlementStatus === undefined
1863
1866
  ? {}
@@ -1892,6 +1895,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1892
1895
  'parentCallId',
1893
1896
  'request',
1894
1897
  'inputs',
1898
+ 'attachments',
1895
1899
  'options',
1896
1900
  'roleBindings',
1897
1901
  'runtime',
@@ -1922,6 +1926,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1922
1926
  ...(parentCallId === undefined ? {} : { parentCallId }),
1923
1927
  ...(frame.request === undefined ? {} : { request: frame.request }),
1924
1928
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs }),
1929
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
1925
1930
  options,
1926
1931
  roleBindings,
1927
1932
  runtime,
@@ -2114,6 +2119,7 @@ function snapshotSessionAgent(value, path) {
2114
2119
  'fastMode',
2115
2120
  'subagentModel',
2116
2121
  'subagentEffort',
2122
+ 'browser',
2117
2123
  'instruction',
2118
2124
  'permissions',
2119
2125
  ], path);
@@ -2121,7 +2127,8 @@ function snapshotSessionAgent(value, path) {
2121
2127
  key !== 'effort' &&
2122
2128
  key !== 'fastMode' &&
2123
2129
  key !== 'subagentModel' &&
2124
- key !== 'subagentEffort')), path);
2130
+ key !== 'subagentEffort' &&
2131
+ key !== 'browser')), path);
2125
2132
  return {
2126
2133
  adapter: fixed.adapter,
2127
2134
  ...(fixed.instruction === undefined
@@ -2130,6 +2137,7 @@ function snapshotSessionAgent(value, path) {
2130
2137
  ...(fixed.permissions === undefined
2131
2138
  ? {}
2132
2139
  : { permissions: livePermissions(fixed.permissions) }),
2140
+ ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
2133
2141
  model: snapshotTuningSelection(agent.model, `${path}.model`),
2134
2142
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
2135
2143
  ...(agent.fastMode === undefined
@@ -2163,6 +2171,7 @@ function callSettings(agent, tuning = agent) {
2163
2171
  return {
2164
2172
  model: tuning.model,
2165
2173
  effort: tuning.effort,
2174
+ browser: agent.browser ?? false,
2166
2175
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
2167
2176
  ...(tuning.subagentModel === undefined
2168
2177
  ? {}
@@ -2174,6 +2183,9 @@ function callSettings(agent, tuning = agent) {
2174
2183
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
2175
2184
  };
2176
2185
  }
2186
+ function controlCallSettings(agent) {
2187
+ return { ...callSettings(agent), browser: false, mcpServers: {} };
2188
+ }
2177
2189
  function promptIdentity(binding) {
2178
2190
  return binding.model.kind === 'value'
2179
2191
  ? binding.model.value
@@ -2545,6 +2557,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2545
2557
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2546
2558
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
2547
2559
  const continuity = deps.continuity;
2560
+ const resolveAttachments = deps.resolveAttachments;
2561
+ const beginPreparationMedia = deps.beginPreparationMedia;
2562
+ const onTurnAttachments = deps.onTurnAttachments;
2563
+ let nextTurnAttachments = [];
2564
+ let pendingAttachments = [];
2565
+ const mergeAttachments = (...groups) => [...new Map(groups.flat().map((reference) => [reference.assetId, reference])).values()];
2566
+ const attachmentPaths = async (references, signal) => {
2567
+ if (!references?.length)
2568
+ return undefined;
2569
+ if (!resolveAttachments)
2570
+ throw new Error('The session host cannot materialize required attachments');
2571
+ return resolveAttachments(references, signal);
2572
+ };
2548
2573
  const abortPreparation = deps.abortPreparation;
2549
2574
  let pendingHostCapabilities = deps.hostCapabilities;
2550
2575
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
@@ -2866,6 +2891,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2866
2891
  'parentCallId',
2867
2892
  'request',
2868
2893
  'inputs',
2894
+ 'attachments',
2869
2895
  'options',
2870
2896
  'roleBindings',
2871
2897
  'runtime',
@@ -2951,6 +2977,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2951
2977
  ...(parentCallId === undefined ? {} : { parentCallId }),
2952
2978
  ...(frame.request === undefined ? {} : { request: frame.request }),
2953
2979
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs }),
2980
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
2954
2981
  options,
2955
2982
  roleBindings,
2956
2983
  runtime,
@@ -3324,12 +3351,20 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3324
3351
  turnCalls?.add(tracked);
3325
3352
  return tracked;
3326
3353
  };
3327
- const callCaptainQueued = (frame, context, prompt, options, signal) => {
3354
+ const callCaptainQueued = (frame, context, prompt, options, signal, media) => {
3328
3355
  const queued = captainQueue.add(async () => {
3329
3356
  signal.throwIfAborted();
3330
- const result = await trackHostCall(frame, context.callCaptain(prompt, options));
3357
+ const attachments = media?.attachments?.length ? await attachmentPaths(media.attachments, signal) : undefined;
3331
3358
  signal.throwIfAborted();
3332
- return result;
3359
+ const endMedia = media?.preparation ? beginPreparationMedia?.(frame.sessionId) : undefined;
3360
+ try {
3361
+ const result = await trackHostCall(frame, context.callCaptain(prompt, { ...options, ...(attachments === undefined ? {} : { attachments }) }));
3362
+ signal.throwIfAborted();
3363
+ return result;
3364
+ }
3365
+ finally {
3366
+ endMedia?.();
3367
+ }
3333
3368
  });
3334
3369
  return trackHostCall(frame, queued);
3335
3370
  };
@@ -3398,9 +3433,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3398
3433
  !Number.isSafeInteger(trace.turnId) ||
3399
3434
  trace.turnId <= 0 ||
3400
3435
  !Number.isSafeInteger(trace.sequence) ||
3401
- trace.sequence <= 0 ||
3402
- summary === undefined ||
3403
- !summaryIncludes(frame)) {
3436
+ trace.sequence <= 0) {
3437
+ return;
3438
+ }
3439
+ if (trace.type === 'player.call.finished') {
3440
+ const call = payloadRecord(trace.payload);
3441
+ if (typeof trace.callId === 'string' && call?.status === 'ok' &&
3442
+ typeof call.roleId === 'string' && typeof call.playerId === 'string' &&
3443
+ frame.playerBindings.get(call.roleId)?.playerId === call.playerId &&
3444
+ typeof call.stateId === 'string' && typeof call.finalText === 'string') {
3445
+ turn.workerCalls.set(`${frame.sessionId}:${trace.callId}`, { frame, callId: trace.callId, roleId: call.roleId, playerId: call.playerId, stateId: call.stateId, finalText: call.finalText });
3446
+ }
3404
3447
  return;
3405
3448
  }
3406
3449
  if (trace.type !== 'outcome.accepted')
@@ -3419,6 +3462,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3419
3462
  receipt.acceptedOutcome.trim().length === 0) {
3420
3463
  return;
3421
3464
  }
3465
+ const ledger = currentEffectLedger();
3466
+ const previous = turn.progressBase?.effectLedger;
3467
+ for (const call of turn.workerCalls.values()) {
3468
+ if (call.frame !== frame || call.stateId !== receipt.source)
3469
+ continue;
3470
+ const boundary = [...ledger.boundaries].reverse().find((entry) => entry.playbookId === frame.entry.id &&
3471
+ entry.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
3472
+ entry.callId === call.callId && entry.roleId === call.roleId &&
3473
+ entry.sourceStateId === call.stateId && entry.finalText === call.finalText &&
3474
+ !isDeepStrictEqual(entry, previous?.boundaries[entry.sequence - 1]));
3475
+ if (!boundary || turn.workerReports.has(boundary.boundaryId))
3476
+ continue;
3477
+ const report = workerReportExcerpt(boundary, call.playerId, receipt.acceptedOutcome);
3478
+ if (report !== undefined)
3479
+ turn.workerReports.set(boundary.boundaryId, report);
3480
+ }
3481
+ if (summary === undefined || !summaryIncludes(frame))
3482
+ return;
3422
3483
  const traceKey = `${frame.sessionId}:${trace.sequence}`;
3423
3484
  if (summary.acceptedOutcomeTraceKeys.has(traceKey))
3424
3485
  return;
@@ -3438,7 +3499,27 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3438
3499
  const createPorts = (frame) => ({
3439
3500
  ...(recordProgress === undefined ? {} : { recordStep: async (step, position) => {
3440
3501
  journalledFrames.add(frame);
3441
- await saveProgress(step.result === undefined ? { frame, position } : undefined, { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id });
3502
+ const turn = activeTurn;
3503
+ if (step.kind === 'player' && step.result === undefined) {
3504
+ turn?.workerStepStarts.set(step.id, new Set(turn.workerCalls.keys()));
3505
+ }
3506
+ let workerEvidence;
3507
+ const guard = payloadRecord(step.result)?.guard;
3508
+ const before = turn?.workerStepStarts.get(step.id);
3509
+ if (step.kind === 'player' && typeof guard === 'string' && before !== undefined && turn) {
3510
+ const candidates = [...turn.workerCalls.entries()].flatMap(([key, call]) => {
3511
+ if (before.has(key) || call.frame !== frame || call.stateId !== step.stateId)
3512
+ return [];
3513
+ return currentEffectLedger().boundaries.filter((boundary) => boundary.playbookId === frame.entry.id && boundary.sourceStateId === step.stateId &&
3514
+ boundary.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
3515
+ boundary.callId === call.callId && boundary.roleId === call.roleId && boundary.finalText === call.finalText &&
3516
+ !isDeepStrictEqual(boundary, turn.progressBase?.effectLedger.boundaries[boundary.sequence - 1]) &&
3517
+ workerReportExcerpt(boundary, call.playerId, guard) !== undefined).map((boundary) => ({ boundaryId: boundary.boundaryId, playerId: call.playerId }));
3518
+ });
3519
+ if (candidates.length === 1)
3520
+ workerEvidence = candidates[0];
3521
+ }
3522
+ await saveProgress(step.result === undefined ? { frame, position } : undefined, { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id, ...(workerEvidence === undefined ? {} : { workerEvidence }) });
3442
3523
  } }),
3443
3524
  callPlayer: async (roleId, prompt, signal, options) => {
3444
3525
  admitHostBoundary();
@@ -3465,6 +3546,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3465
3546
  throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
3466
3547
  }
3467
3548
  const settings = callSettings(binding.agent, binding);
3549
+ const attachments = await attachmentPaths(frame.attachments, signal);
3468
3550
  const calling = {
3469
3551
  phase: 'calling',
3470
3552
  frame,
@@ -3489,7 +3571,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3489
3571
  const call = async (resume) => {
3490
3572
  await continuity?.beforeCall(binding.playerId);
3491
3573
  signal.throwIfAborted();
3492
- const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings })));
3574
+ const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings, ...(attachments === undefined ? {} : { attachments }) })));
3493
3575
  hostResolved = true;
3494
3576
  return raw;
3495
3577
  };
@@ -3611,8 +3693,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3611
3693
  const result = await callCaptainQueued(frame, activeContext, prompt, {
3612
3694
  visibility: options.visibility,
3613
3695
  resume: options.resume,
3696
+ settings: options.allowedTools?.length === 0 ? controlCallSettings(captainAgent) : callSettings(captainAgent),
3614
3697
  ...forwardedToolOptions(options.allowedTools, captainAdapter),
3615
- }, signal);
3698
+ }, signal, { attachments: options.allowedTools?.length === 0 ? undefined : frame.attachments });
3616
3699
  const output = {
3617
3700
  status: result.status,
3618
3701
  ...(result.finalText !== undefined
@@ -3630,6 +3713,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3630
3713
  throw new Error('callJudge invoked outside a Boss turn');
3631
3714
  }
3632
3715
  const result = await callCaptainQueued(frame, activeContext, hiddenJudgeEnvelope(prompt), {
3716
+ settings: controlCallSettings(captainAgent),
3633
3717
  visibility: 'hidden',
3634
3718
  resume: false,
3635
3719
  ...controlCallToolOptions(captainAdapter),
@@ -3764,6 +3848,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3764
3848
  runtime,
3765
3849
  sessionId,
3766
3850
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
3851
+ ...((parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn?.attachments)?.length ? { attachments: [...(parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn.attachments)] } : {}),
3767
3852
  depth: parent ? parent.frame.depth + 1 : 0,
3768
3853
  playerBindings,
3769
3854
  ...(parent ? { parent } : {}),
@@ -3779,12 +3864,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3779
3864
  enablement,
3780
3865
  runtime,
3781
3866
  sessionId: snapshot.sessionId,
3867
+ effectOwnerSessionId: snapshot.runtime.retainedEffectSourceSessionId ?? snapshot.sessionId,
3782
3868
  rootSessionId: snapshot.rootSessionId,
3783
3869
  depth: snapshot.depth,
3784
3870
  playerBindings,
3785
3871
  ...(parent ? { parent } : {}),
3786
3872
  ...(snapshot.request === undefined ? {} : { request: snapshot.request }),
3787
3873
  ...(snapshot.inputs === undefined ? {} : { inputs: [...snapshot.inputs] }),
3874
+ ...(snapshot.attachments === undefined ? {} : { attachments: [...snapshot.attachments] }),
3788
3875
  state: snapshot.runtime.state,
3789
3876
  inFlightHostCalls: new Set(),
3790
3877
  };
@@ -4065,6 +4152,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4065
4152
  finally {
4066
4153
  if (leafFrame() === frame) {
4067
4154
  frames.pop();
4155
+ if (!frame.parent)
4156
+ pendingAttachments = [];
4068
4157
  if (frame.parent) {
4069
4158
  pendingChildParents.delete(frame.parent.frame);
4070
4159
  }
@@ -5328,11 +5417,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5328
5417
  await continuity?.beforeCall('captain');
5329
5418
  context.signal.throwIfAborted();
5330
5419
  attempt.providerBoundaryEntered = true;
5331
- const result = await classifySettingsCall(() => context.callCaptain(prompt, {
5420
+ const result = await classifySettingsCall(() => 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, {
5332
5421
  visibility: 'hidden',
5333
5422
  resume,
5334
5423
  ...controlCallToolOptions(captainAdapter),
5335
- settings: callSettings(captainAgent),
5424
+ settings: controlCallSettings(captainAgent),
5336
5425
  }));
5337
5426
  context.signal.throwIfAborted();
5338
5427
  return result;
@@ -5497,6 +5586,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5497
5586
  ...(kind === 'decision'
5498
5587
  ? [labeledBlock('Catalog digest', catalogDigest())]
5499
5588
  : []),
5589
+ ...(kind === 'closingReply' && turn?.workerReports.size
5590
+ ? [workerReportBlock([...turn.workerReports.values()])]
5591
+ : []),
5500
5592
  ...(kind === 'closingReply' && turn?.report
5501
5593
  ? [
5502
5594
  labeledBlock('ControlView digest', controlViewDigest()),
@@ -5821,6 +5913,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5821
5913
  enablement,
5822
5914
  runtime: offer.runtimes[index],
5823
5915
  sessionId: targetSessionIds[index],
5916
+ effectOwnerSessionId: sourceFrame.runtime.retainedEffectSourceSessionId ?? sourceFrame.sessionId,
5824
5917
  rootSessionId: targetRootSessionId,
5825
5918
  depth: index,
5826
5919
  playerBindings: makePlayerBindings(enablement),
@@ -5829,6 +5922,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5829
5922
  : {}),
5830
5923
  ...(sourceFrame.request === undefined ? {} : { request: sourceFrame.request }),
5831
5924
  ...(sourceFrame.inputs === undefined ? {} : { inputs: [...sourceFrame.inputs] }),
5925
+ ...(sourceFrame.attachments === undefined ? {} : { attachments: [...sourceFrame.attachments] }),
5832
5926
  state: sourceFrame.runtime.state,
5833
5927
  inFlightHostCalls: new Set(),
5834
5928
  });
@@ -6383,6 +6477,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6383
6477
  return rejectSelection(selection, `/${enablementById.get(entry.id).command} is already on the active path`);
6384
6478
  }
6385
6479
  }
6480
+ const selectedIds = selection.attachmentIds;
6481
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leafFrame()?.attachments ?? []).map((reference) => [reference.assetId, reference]));
6482
+ if (selectedIds?.some((id) => !available.has(id)) || (selectedIds && new Set(selectedIds).size !== selectedIds.length))
6483
+ return rejectSelection(selection, 'the request selects unavailable attachment evidence');
6484
+ turn.selectedAttachments = selectedIds === undefined ? turn.attachments : selectedIds.map((id) => available.get(id));
6485
+ pendingAttachments = [];
6386
6486
  turn.settled = true;
6387
6487
  journalAction({
6388
6488
  action: selection.action,
@@ -6515,7 +6615,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6515
6615
  `Later Boss input (context only): ${JSON.stringify(laterInputs)}`,
6516
6616
  `Existing instructions: ${JSON.stringify(instructions)}`,
6517
6617
  `Pending questions (quoted evidence): ${JSON.stringify(before.pendingQuestions.map(({ question }) => question))}`,
6518
- ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: callSettings(captainAgent) }, signal);
6618
+ ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: controlCallSettings(captainAgent) }, signal);
6519
6619
  const answer = result.status === 'ok' ? controlReplyJson(result.finalText) : undefined;
6520
6620
  const index = answer && typeof answer === 'object' && !Array.isArray(answer) && Object.keys(answer).join(',') === 'instructionIndex'
6521
6621
  ? answer.instructionIndex : undefined;
@@ -6573,7 +6673,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6573
6673
  try {
6574
6674
  claim = await capability.acquire({ signal: preparationSignal });
6575
6675
  await claim.assertOwner();
6576
- const result = await runEffect(() => callCaptainQueued(leaf, context, preparationPrompt, { visibility: 'hidden', resume: false, settings: callSettings(captainAgent) }, preparationSignal));
6676
+ const result = await runEffect(() => callCaptainQueued(leaf, context, preparationPrompt, { visibility: 'hidden', resume: false, settings: callSettings(captainAgent) }, preparationSignal, { attachments: leaf.attachments, preparation: true }));
6577
6677
  preparationSignal.throwIfAborted();
6578
6678
  await claim.assertOwner();
6579
6679
  if (result.status !== 'ok') {
@@ -6616,6 +6716,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6616
6716
  : { action: 'runtime', actionId: offer.continuation.actionId };
6617
6717
  }
6618
6718
  if (selection.action === 'deliver') {
6719
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leaf.attachments ?? []).map((reference) => [reference.assetId, reference]));
6720
+ if (selection.attachmentIds?.some((id) => !available.has(id)) || (selection.attachmentIds && new Set(selection.attachmentIds).size !== selection.attachmentIds.length))
6721
+ return rejectSelection(selection, 'the request selects unavailable attachment evidence');
6722
+ const selected = selection.attachmentIds === undefined ? turn.attachments : selection.attachmentIds.map((id) => available.get(id));
6723
+ const nextAttachments = mergeAttachments(leaf.attachments ?? [], selected);
6724
+ if (nextAttachments.length)
6725
+ leaf.attachments = nextAttachments;
6619
6726
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
6620
6727
  // for that text — any text carried on the selection is ignored.
6621
6728
  turn.settled = true;
@@ -7154,6 +7261,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7154
7261
  : {}),
7155
7262
  ...(frame.request === undefined ? {} : { request: frame.request }),
7156
7263
  ...(frame.inputs === undefined ? {} : { inputs: [...frame.inputs] }),
7264
+ ...(frame.attachments === undefined ? {} : { attachments: [...frame.attachments] }),
7157
7265
  options: frame.enablement.options,
7158
7266
  roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
7159
7267
  role,
@@ -7365,6 +7473,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7365
7473
  issuedSessionIds: [...issuedSessionIds],
7366
7474
  sequences: { turn: turnSequence, journal: journalSeq },
7367
7475
  journal,
7476
+ ...(pendingAttachments.length ? { pendingAttachments } : {}),
7368
7477
  ...(lastAction === undefined ? {} : { lastAction }),
7369
7478
  ...(lastSettlementStatus === undefined
7370
7479
  ? {}
@@ -7659,6 +7768,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7659
7768
  for (const id of snapshot.issuedSessionIds)
7660
7769
  issuedSessionIds.add(id);
7661
7770
  journal.push(...snapshot.journal);
7771
+ pendingAttachments = [...(snapshot.pendingAttachments ?? [])];
7662
7772
  journalSeq = snapshot.sequences.journal;
7663
7773
  turnSequence = snapshot.sequences.turn;
7664
7774
  conversation = snapshot.captain.conversation;
@@ -7790,10 +7900,29 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7790
7900
  selectInterruptedReport(input, report) {
7791
7901
  if (activeTurn)
7792
7902
  throw new Error('A Captain turn is already active');
7793
- if (!input.trim() || !report.text.trim())
7903
+ if ((!input.trim() && nextTurnAttachments.length === 0) || !report.text.trim())
7794
7904
  throw new Error('Interruption report requires its recorded input and explanation');
7795
7905
  pendingHostSelection = { kind: 'report', text: input, report };
7796
7906
  },
7907
+ recordMediaEvidence(reference, runtimeSessionId) {
7908
+ const asset = validateAssetRef(reference);
7909
+ if (!activeTurn)
7910
+ throw new Error('Media evidence arrived outside an active turn');
7911
+ activeTurn.evidence = mergeAttachments(activeTurn.evidence ?? [], [asset]);
7912
+ const producingIndex = frames.findIndex((candidate) => candidate.sessionId === runtimeSessionId);
7913
+ // Observed output belongs to this engagement's reasoning context: a
7914
+ // resumed parent may inspect its child's figure. Boss input additions
7915
+ // still target only the addressed leaf, and a new root inherits none.
7916
+ for (const frame of frames.slice(0, producingIndex + 1)) {
7917
+ frame.attachments = mergeAttachments(frame.attachments ?? [], [asset]);
7918
+ }
7919
+ },
7920
+ setTurnAttachments(references) {
7921
+ if (activeTurn || activeTurnHostCalls)
7922
+ throw new Error('A Captain turn is already active');
7923
+ nextTurnAttachments = validateAssetRefs(references);
7924
+ onTurnAttachments?.(nextTurnAttachments);
7925
+ },
7797
7926
  async handleBossTurn(turn, context) {
7798
7927
  if (lifecycle !== 'ready' || terminallyDisposed) {
7799
7928
  throw new Error('init must be called first, or restore must complete before handling a Boss turn');
@@ -7812,7 +7941,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7812
7941
  // telemetry (CAPTAIN-7).
7813
7942
  retentionSettlementReady = false;
7814
7943
  abandonmentSettlementUnsafe = false;
7815
- if (turn.prompt.trim().length === 0)
7944
+ const attachments = nextTurnAttachments;
7945
+ nextTurnAttachments = [];
7946
+ if (turn.prompt.trim().length === 0 && attachments.length === 0)
7816
7947
  return;
7817
7948
  const progressBase = exportShellSnapshot();
7818
7949
  settledTurnUnresolvedEffects = undefined;
@@ -7840,6 +7971,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7840
7971
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
7841
7972
  const parsed = decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
7842
7973
  activeTurn = {
7974
+ attachments,
7975
+ workerReports: new Map(),
7976
+ workerStepStarts: new Map(),
7977
+ workerCalls: new Map(),
7843
7978
  appliedActionCount: 0,
7844
7979
  stoppedFrames: new Set(),
7845
7980
  ...(progressBase === undefined ? {} : { progressBase }),
@@ -7873,7 +8008,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7873
8008
  }
7874
8009
  }
7875
8010
  decisionCall = undefined;
7876
- appendJournal('boss', turn.prompt);
8011
+ appendJournal('boss', attachments.length ? { text: turn.prompt, attachments } : turn.prompt);
8012
+ pendingAttachments = mergeAttachments(pendingAttachments, attachments);
7877
8013
  try {
7878
8014
  await drainRetiredRetainedRuntimes();
7879
8015
  await prepareRetainedGenerationOffers();
@@ -7882,6 +8018,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7882
8018
  await settleSelection({ action: 'respond', text: activeTurn.interruptedReport.text }, context.signal);
7883
8019
  return;
7884
8020
  }
8021
+ if (!turn.prompt.trim() && attachments.length) {
8022
+ markConversationCatchUp();
8023
+ await settleSelection({ action: 'respond', text: 'What would you like me to do with the attached material?' }, context.signal);
8024
+ return;
8025
+ }
7885
8026
  const result = await captainRuntime.handleBossInput({
7886
8027
  text: turn.prompt,
7887
8028
  signal: context.signal,