@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
@@ -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,
@@ -2098,7 +2103,7 @@ function snapshotFastMode(value, path) {
2098
2103
  }
2099
2104
  return value;
2100
2105
  }
2101
- function snapshotSubagentModel(value, path) {
2106
+ function snapshotNonblankTuning(value, path) {
2102
2107
  if (value !== undefined &&
2103
2108
  (typeof value !== 'string' || value.trim().length === 0)) {
2104
2109
  throw new TypeError(`${path} must be a nonblank string`);
@@ -2113,13 +2118,17 @@ function snapshotSessionAgent(value, path) {
2113
2118
  'effort',
2114
2119
  'fastMode',
2115
2120
  'subagentModel',
2121
+ 'subagentEffort',
2122
+ 'browser',
2116
2123
  'instruction',
2117
2124
  'permissions',
2118
2125
  ], path);
2119
2126
  const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' &&
2120
2127
  key !== 'effort' &&
2121
2128
  key !== 'fastMode' &&
2122
- key !== 'subagentModel')), path);
2129
+ key !== 'subagentModel' &&
2130
+ key !== 'subagentEffort' &&
2131
+ key !== 'browser')), path);
2123
2132
  return {
2124
2133
  adapter: fixed.adapter,
2125
2134
  ...(fixed.instruction === undefined
@@ -2128,6 +2137,7 @@ function snapshotSessionAgent(value, path) {
2128
2137
  ...(fixed.permissions === undefined
2129
2138
  ? {}
2130
2139
  : { permissions: livePermissions(fixed.permissions) }),
2140
+ ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
2131
2141
  model: snapshotTuningSelection(agent.model, `${path}.model`),
2132
2142
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
2133
2143
  ...(agent.fastMode === undefined
@@ -2136,7 +2146,12 @@ function snapshotSessionAgent(value, path) {
2136
2146
  ...(agent.subagentModel === undefined
2137
2147
  ? {}
2138
2148
  : {
2139
- subagentModel: snapshotSubagentModel(agent.subagentModel, `${path}.subagentModel`),
2149
+ subagentModel: snapshotNonblankTuning(agent.subagentModel, `${path}.subagentModel`),
2150
+ }),
2151
+ ...(agent.subagentEffort === undefined
2152
+ ? {}
2153
+ : {
2154
+ subagentEffort: snapshotNonblankTuning(agent.subagentEffort, `${path}.subagentEffort`),
2140
2155
  }),
2141
2156
  };
2142
2157
  }
@@ -2150,18 +2165,27 @@ function fixedAgent(agent) {
2150
2165
  function callSettings(agent, tuning = agent) {
2151
2166
  // cligent treats supplied call settings as a complete replacement, so an
2152
2167
  // omitted fastMode or subagentModel here is a request for the provider
2153
- // default, never an inheritance of whatever the previous call left behind.
2168
+ // default, and an omitted subagentEffort leaves each subagent's effort to
2169
+ // the agent — never an inheritance of whatever the previous call left
2170
+ // behind.
2154
2171
  return {
2155
2172
  model: tuning.model,
2156
2173
  effort: tuning.effort,
2174
+ browser: agent.browser ?? false,
2157
2175
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
2158
2176
  ...(tuning.subagentModel === undefined
2159
2177
  ? {}
2160
2178
  : { subagentModel: tuning.subagentModel }),
2179
+ ...(tuning.subagentEffort === undefined
2180
+ ? {}
2181
+ : { subagentEffort: tuning.subagentEffort }),
2161
2182
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
2162
2183
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
2163
2184
  };
2164
2185
  }
2186
+ function controlCallSettings(agent) {
2187
+ return { ...callSettings(agent), browser: false, mcpServers: {} };
2188
+ }
2165
2189
  function promptIdentity(binding) {
2166
2190
  return binding.model.kind === 'value'
2167
2191
  ? binding.model.value
@@ -2285,7 +2309,14 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
2285
2309
  for (const role of entry.requiredRoleIds) {
2286
2310
  const path = `captain.options.playbooks.${id}.roles.${role}`;
2287
2311
  const rawBinding = snapshotRecord(roleRecord[role], path);
2288
- rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort', 'fastMode', 'subagentModel'], path);
2312
+ rejectSnapshotKeys(rawBinding, [
2313
+ 'playerId',
2314
+ 'model',
2315
+ 'effort',
2316
+ 'fastMode',
2317
+ 'subagentModel',
2318
+ 'subagentEffort',
2319
+ ], path);
2289
2320
  const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
2290
2321
  if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2291
2322
  throw new Error(`${path}.playerId is not a canonical player id`);
@@ -2306,7 +2337,12 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
2306
2337
  ...(rawBinding.subagentModel === undefined
2307
2338
  ? {}
2308
2339
  : {
2309
- subagentModel: snapshotSubagentModel(rawBinding.subagentModel, `${path}.subagentModel`),
2340
+ subagentModel: snapshotNonblankTuning(rawBinding.subagentModel, `${path}.subagentModel`),
2341
+ }),
2342
+ ...(rawBinding.subagentEffort === undefined
2343
+ ? {}
2344
+ : {
2345
+ subagentEffort: snapshotNonblankTuning(rawBinding.subagentEffort, `${path}.subagentEffort`),
2310
2346
  }),
2311
2347
  agent,
2312
2348
  });
@@ -2521,6 +2557,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2521
2557
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2522
2558
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
2523
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
+ };
2524
2573
  const abortPreparation = deps.abortPreparation;
2525
2574
  let pendingHostCapabilities = deps.hostCapabilities;
2526
2575
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
@@ -2842,6 +2891,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2842
2891
  'parentCallId',
2843
2892
  'request',
2844
2893
  'inputs',
2894
+ 'attachments',
2845
2895
  'options',
2846
2896
  'roleBindings',
2847
2897
  'runtime',
@@ -2927,6 +2977,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2927
2977
  ...(parentCallId === undefined ? {} : { parentCallId }),
2928
2978
  ...(frame.request === undefined ? {} : { request: frame.request }),
2929
2979
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs }),
2980
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
2930
2981
  options,
2931
2982
  roleBindings,
2932
2983
  runtime,
@@ -3300,12 +3351,20 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3300
3351
  turnCalls?.add(tracked);
3301
3352
  return tracked;
3302
3353
  };
3303
- const callCaptainQueued = (frame, context, prompt, options, signal) => {
3354
+ const callCaptainQueued = (frame, context, prompt, options, signal, media) => {
3304
3355
  const queued = captainQueue.add(async () => {
3305
3356
  signal.throwIfAborted();
3306
- const result = await trackHostCall(frame, context.callCaptain(prompt, options));
3357
+ const attachments = media?.attachments?.length ? await attachmentPaths(media.attachments, signal) : undefined;
3307
3358
  signal.throwIfAborted();
3308
- 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
+ }
3309
3368
  });
3310
3369
  return trackHostCall(frame, queued);
3311
3370
  };
@@ -3374,9 +3433,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3374
3433
  !Number.isSafeInteger(trace.turnId) ||
3375
3434
  trace.turnId <= 0 ||
3376
3435
  !Number.isSafeInteger(trace.sequence) ||
3377
- trace.sequence <= 0 ||
3378
- summary === undefined ||
3379
- !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
+ }
3380
3447
  return;
3381
3448
  }
3382
3449
  if (trace.type !== 'outcome.accepted')
@@ -3395,6 +3462,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3395
3462
  receipt.acceptedOutcome.trim().length === 0) {
3396
3463
  return;
3397
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;
3398
3483
  const traceKey = `${frame.sessionId}:${trace.sequence}`;
3399
3484
  if (summary.acceptedOutcomeTraceKeys.has(traceKey))
3400
3485
  return;
@@ -3414,7 +3499,27 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3414
3499
  const createPorts = (frame) => ({
3415
3500
  ...(recordProgress === undefined ? {} : { recordStep: async (step, position) => {
3416
3501
  journalledFrames.add(frame);
3417
- 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 }) });
3418
3523
  } }),
3419
3524
  callPlayer: async (roleId, prompt, signal, options) => {
3420
3525
  admitHostBoundary();
@@ -3441,6 +3546,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3441
3546
  throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
3442
3547
  }
3443
3548
  const settings = callSettings(binding.agent, binding);
3549
+ const attachments = await attachmentPaths(frame.attachments, signal);
3444
3550
  const calling = {
3445
3551
  phase: 'calling',
3446
3552
  frame,
@@ -3465,7 +3571,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3465
3571
  const call = async (resume) => {
3466
3572
  await continuity?.beforeCall(binding.playerId);
3467
3573
  signal.throwIfAborted();
3468
- 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 }) })));
3469
3575
  hostResolved = true;
3470
3576
  return raw;
3471
3577
  };
@@ -3587,8 +3693,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3587
3693
  const result = await callCaptainQueued(frame, activeContext, prompt, {
3588
3694
  visibility: options.visibility,
3589
3695
  resume: options.resume,
3696
+ settings: options.allowedTools?.length === 0 ? controlCallSettings(captainAgent) : callSettings(captainAgent),
3590
3697
  ...forwardedToolOptions(options.allowedTools, captainAdapter),
3591
- }, signal);
3698
+ }, signal, { attachments: options.allowedTools?.length === 0 ? undefined : frame.attachments });
3592
3699
  const output = {
3593
3700
  status: result.status,
3594
3701
  ...(result.finalText !== undefined
@@ -3606,6 +3713,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3606
3713
  throw new Error('callJudge invoked outside a Boss turn');
3607
3714
  }
3608
3715
  const result = await callCaptainQueued(frame, activeContext, hiddenJudgeEnvelope(prompt), {
3716
+ settings: controlCallSettings(captainAgent),
3609
3717
  visibility: 'hidden',
3610
3718
  resume: false,
3611
3719
  ...controlCallToolOptions(captainAdapter),
@@ -3740,6 +3848,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3740
3848
  runtime,
3741
3849
  sessionId,
3742
3850
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
3851
+ ...((parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn?.attachments)?.length ? { attachments: [...(parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn.attachments)] } : {}),
3743
3852
  depth: parent ? parent.frame.depth + 1 : 0,
3744
3853
  playerBindings,
3745
3854
  ...(parent ? { parent } : {}),
@@ -3755,12 +3864,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3755
3864
  enablement,
3756
3865
  runtime,
3757
3866
  sessionId: snapshot.sessionId,
3867
+ effectOwnerSessionId: snapshot.runtime.retainedEffectSourceSessionId ?? snapshot.sessionId,
3758
3868
  rootSessionId: snapshot.rootSessionId,
3759
3869
  depth: snapshot.depth,
3760
3870
  playerBindings,
3761
3871
  ...(parent ? { parent } : {}),
3762
3872
  ...(snapshot.request === undefined ? {} : { request: snapshot.request }),
3763
3873
  ...(snapshot.inputs === undefined ? {} : { inputs: [...snapshot.inputs] }),
3874
+ ...(snapshot.attachments === undefined ? {} : { attachments: [...snapshot.attachments] }),
3764
3875
  state: snapshot.runtime.state,
3765
3876
  inFlightHostCalls: new Set(),
3766
3877
  };
@@ -4041,6 +4152,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4041
4152
  finally {
4042
4153
  if (leafFrame() === frame) {
4043
4154
  frames.pop();
4155
+ if (!frame.parent)
4156
+ pendingAttachments = [];
4044
4157
  if (frame.parent) {
4045
4158
  pendingChildParents.delete(frame.parent.frame);
4046
4159
  }
@@ -5304,11 +5417,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5304
5417
  await continuity?.beforeCall('captain');
5305
5418
  context.signal.throwIfAborted();
5306
5419
  attempt.providerBoundaryEntered = true;
5307
- 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, {
5308
5421
  visibility: 'hidden',
5309
5422
  resume,
5310
5423
  ...controlCallToolOptions(captainAdapter),
5311
- settings: callSettings(captainAgent),
5424
+ settings: controlCallSettings(captainAgent),
5312
5425
  }));
5313
5426
  context.signal.throwIfAborted();
5314
5427
  return result;
@@ -5473,6 +5586,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5473
5586
  ...(kind === 'decision'
5474
5587
  ? [labeledBlock('Catalog digest', catalogDigest())]
5475
5588
  : []),
5589
+ ...(kind === 'closingReply' && turn?.workerReports.size
5590
+ ? [workerReportBlock([...turn.workerReports.values()])]
5591
+ : []),
5476
5592
  ...(kind === 'closingReply' && turn?.report
5477
5593
  ? [
5478
5594
  labeledBlock('ControlView digest', controlViewDigest()),
@@ -5797,6 +5913,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5797
5913
  enablement,
5798
5914
  runtime: offer.runtimes[index],
5799
5915
  sessionId: targetSessionIds[index],
5916
+ effectOwnerSessionId: sourceFrame.runtime.retainedEffectSourceSessionId ?? sourceFrame.sessionId,
5800
5917
  rootSessionId: targetRootSessionId,
5801
5918
  depth: index,
5802
5919
  playerBindings: makePlayerBindings(enablement),
@@ -5805,6 +5922,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5805
5922
  : {}),
5806
5923
  ...(sourceFrame.request === undefined ? {} : { request: sourceFrame.request }),
5807
5924
  ...(sourceFrame.inputs === undefined ? {} : { inputs: [...sourceFrame.inputs] }),
5925
+ ...(sourceFrame.attachments === undefined ? {} : { attachments: [...sourceFrame.attachments] }),
5808
5926
  state: sourceFrame.runtime.state,
5809
5927
  inFlightHostCalls: new Set(),
5810
5928
  });
@@ -6359,6 +6477,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6359
6477
  return rejectSelection(selection, `/${enablementById.get(entry.id).command} is already on the active path`);
6360
6478
  }
6361
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 = [];
6362
6486
  turn.settled = true;
6363
6487
  journalAction({
6364
6488
  action: selection.action,
@@ -6491,7 +6615,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6491
6615
  `Later Boss input (context only): ${JSON.stringify(laterInputs)}`,
6492
6616
  `Existing instructions: ${JSON.stringify(instructions)}`,
6493
6617
  `Pending questions (quoted evidence): ${JSON.stringify(before.pendingQuestions.map(({ question }) => question))}`,
6494
- ].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);
6495
6619
  const answer = result.status === 'ok' ? controlReplyJson(result.finalText) : undefined;
6496
6620
  const index = answer && typeof answer === 'object' && !Array.isArray(answer) && Object.keys(answer).join(',') === 'instructionIndex'
6497
6621
  ? answer.instructionIndex : undefined;
@@ -6549,7 +6673,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6549
6673
  try {
6550
6674
  claim = await capability.acquire({ signal: preparationSignal });
6551
6675
  await claim.assertOwner();
6552
- 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 }));
6553
6677
  preparationSignal.throwIfAborted();
6554
6678
  await claim.assertOwner();
6555
6679
  if (result.status !== 'ok') {
@@ -6592,6 +6716,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6592
6716
  : { action: 'runtime', actionId: offer.continuation.actionId };
6593
6717
  }
6594
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;
6595
6726
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
6596
6727
  // for that text — any text carried on the selection is ignored.
6597
6728
  turn.settled = true;
@@ -7130,6 +7261,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7130
7261
  : {}),
7131
7262
  ...(frame.request === undefined ? {} : { request: frame.request }),
7132
7263
  ...(frame.inputs === undefined ? {} : { inputs: [...frame.inputs] }),
7264
+ ...(frame.attachments === undefined ? {} : { attachments: [...frame.attachments] }),
7133
7265
  options: frame.enablement.options,
7134
7266
  roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
7135
7267
  role,
@@ -7341,6 +7473,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7341
7473
  issuedSessionIds: [...issuedSessionIds],
7342
7474
  sequences: { turn: turnSequence, journal: journalSeq },
7343
7475
  journal,
7476
+ ...(pendingAttachments.length ? { pendingAttachments } : {}),
7344
7477
  ...(lastAction === undefined ? {} : { lastAction }),
7345
7478
  ...(lastSettlementStatus === undefined
7346
7479
  ? {}
@@ -7635,6 +7768,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7635
7768
  for (const id of snapshot.issuedSessionIds)
7636
7769
  issuedSessionIds.add(id);
7637
7770
  journal.push(...snapshot.journal);
7771
+ pendingAttachments = [...(snapshot.pendingAttachments ?? [])];
7638
7772
  journalSeq = snapshot.sequences.journal;
7639
7773
  turnSequence = snapshot.sequences.turn;
7640
7774
  conversation = snapshot.captain.conversation;
@@ -7766,10 +7900,29 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7766
7900
  selectInterruptedReport(input, report) {
7767
7901
  if (activeTurn)
7768
7902
  throw new Error('A Captain turn is already active');
7769
- if (!input.trim() || !report.text.trim())
7903
+ if ((!input.trim() && nextTurnAttachments.length === 0) || !report.text.trim())
7770
7904
  throw new Error('Interruption report requires its recorded input and explanation');
7771
7905
  pendingHostSelection = { kind: 'report', text: input, report };
7772
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
+ },
7773
7926
  async handleBossTurn(turn, context) {
7774
7927
  if (lifecycle !== 'ready' || terminallyDisposed) {
7775
7928
  throw new Error('init must be called first, or restore must complete before handling a Boss turn');
@@ -7788,7 +7941,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7788
7941
  // telemetry (CAPTAIN-7).
7789
7942
  retentionSettlementReady = false;
7790
7943
  abandonmentSettlementUnsafe = false;
7791
- if (turn.prompt.trim().length === 0)
7944
+ const attachments = nextTurnAttachments;
7945
+ nextTurnAttachments = [];
7946
+ if (turn.prompt.trim().length === 0 && attachments.length === 0)
7792
7947
  return;
7793
7948
  const progressBase = exportShellSnapshot();
7794
7949
  settledTurnUnresolvedEffects = undefined;
@@ -7816,6 +7971,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7816
7971
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
7817
7972
  const parsed = decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
7818
7973
  activeTurn = {
7974
+ attachments,
7975
+ workerReports: new Map(),
7976
+ workerStepStarts: new Map(),
7977
+ workerCalls: new Map(),
7819
7978
  appliedActionCount: 0,
7820
7979
  stoppedFrames: new Set(),
7821
7980
  ...(progressBase === undefined ? {} : { progressBase }),
@@ -7849,7 +8008,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7849
8008
  }
7850
8009
  }
7851
8010
  decisionCall = undefined;
7852
- appendJournal('boss', turn.prompt);
8011
+ appendJournal('boss', attachments.length ? { text: turn.prompt, attachments } : turn.prompt);
8012
+ pendingAttachments = mergeAttachments(pendingAttachments, attachments);
7853
8013
  try {
7854
8014
  await drainRetiredRetainedRuntimes();
7855
8015
  await prepareRetainedGenerationOffers();
@@ -7858,6 +8018,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7858
8018
  await settleSelection({ action: 'respond', text: activeTurn.interruptedReport.text }, context.signal);
7859
8019
  return;
7860
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
+ }
7861
8026
  const result = await captainRuntime.handleBossInput({
7862
8027
  text: turn.prompt,
7863
8028
  signal: context.signal,