@sublang/playbook 17.3.0 → 17.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +5 -2
  2. package/docs/configuration.md +45 -4
  3. package/docs/embedding.md +94 -0
  4. package/package.json +34 -5
  5. package/reference/sdlc/captain.md +6 -4
  6. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
  7. package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
  8. package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
  9. package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
  10. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
  11. package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
  12. package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
  13. package/reference/sdlc/code.md +24 -4
  14. package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
  15. package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
  16. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  17. package/reference/sdlc/code.playbook/bin/run.js +68 -4
  18. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  19. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  20. package/reference/sdlc/code.playbook/bin/session-store.js +145 -13
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +12 -0
  22. package/reference/sdlc/code.playbook/code.fsm.js +40 -6
  23. package/reference/sdlc/code.playbook/code.fsm.ts +53 -6
  24. package/reference/sdlc/code.playbook/code.gears.md +32 -4
  25. package/reference/sdlc/code.playbook/playbook-captain.d.ts +17 -1
  26. package/reference/sdlc/code.playbook/playbook-captain.js +201 -19
  27. package/reference/sdlc/code.playbook/playbook-captain.ts +224 -24
  28. package/reference/sdlc/code.playbook/playbook.config.template.yaml +16 -3
  29. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  30. package/reference/sdlc/code.playbook/session-assets.js +303 -0
  31. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  32. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  33. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  34. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  35. package/reference/sdlc/inspect.md +40 -0
  36. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  37. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  38. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  39. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  40. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  41. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  42. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  43. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  44. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  45. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  46. package/slc/gears2fsm.md +17 -3
  47. package/slc/text2gears.md +7 -0
  48. package/slc/workflow-contracts.json +43 -1
  49. package/src/xstate-playbook-runtime.js +5 -0
  50. package/src/xstate-playbook-runtime.ts +7 -0
@@ -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;
@@ -22,6 +24,15 @@ function retainedEffectLedgerCanRebase(checkpoint, current) {
22
24
  .slice(checkpoint.boundaries.length)
23
25
  .every(({ physicalReceipt }) => physicalReceipt?.classification === 'unchanged');
24
26
  }
27
+ function missingPackagedReview(enablementById, playbookId) {
28
+ const selected = enablementById.get(playbookId);
29
+ if (selected === undefined ||
30
+ enablementById.has('review') ||
31
+ !['code', 'decide'].includes(playbookId) ||
32
+ selected.configuredFrom !== `@sublang/playbook/${playbookId}/registry`)
33
+ return undefined;
34
+ return `/${selected.command} requires enabled playbook "review"; enable REVIEW before starting this packaged workflow`;
35
+ }
25
36
  function createRuntimeForEnablement(enablement, hostCapabilitiesById) {
26
37
  const hostCapabilities = hostCapabilitiesById.get(enablement.entry.id);
27
38
  if (hostCapabilities === undefined) {
@@ -124,7 +135,6 @@ function quoteEvidence(text) {
124
135
  // into a fact and still knows it is foreign — never at the digest renderer,
125
136
  // which sees an opaque payload and cannot tell quoted output from the Boss
126
137
  // text, captain speech, or settlement facts the shell authored itself.
127
- const QUOTED_EVIDENCE_LIMIT = 400;
128
138
  // The same guard for strings the shell interpolates into a single-line fact:
129
139
  // control characters collapse to spaces so a quoted message can never open a
130
140
  // new line — and therefore never a new labeled block — inside a report.
@@ -1685,6 +1695,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1685
1695
  'issuedSessionIds',
1686
1696
  'sequences',
1687
1697
  'journal',
1698
+ 'pendingAttachments',
1688
1699
  'lastAction',
1689
1700
  'lastSettlementStatus',
1690
1701
  'mode',
@@ -1858,6 +1869,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1858
1869
  issuedSessionIds: issued,
1859
1870
  sequences: { turn: turnSequence, journal: journalSequence },
1860
1871
  journal: normalizedJournal,
1872
+ ...(snapshot.pendingAttachments === undefined ? {} : { pendingAttachments: validateAssetRefs(snapshot.pendingAttachments) }),
1861
1873
  ...(lastAction === undefined ? {} : { lastAction }),
1862
1874
  ...(lastSettlementStatus === undefined
1863
1875
  ? {}
@@ -1892,6 +1904,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1892
1904
  'parentCallId',
1893
1905
  'request',
1894
1906
  'inputs',
1907
+ 'attachments',
1895
1908
  'options',
1896
1909
  'roleBindings',
1897
1910
  'runtime',
@@ -1922,6 +1935,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1922
1935
  ...(parentCallId === undefined ? {} : { parentCallId }),
1923
1936
  ...(frame.request === undefined ? {} : { request: frame.request }),
1924
1937
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs }),
1938
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
1925
1939
  options,
1926
1940
  roleBindings,
1927
1941
  runtime,
@@ -2114,6 +2128,7 @@ function snapshotSessionAgent(value, path) {
2114
2128
  'fastMode',
2115
2129
  'subagentModel',
2116
2130
  'subagentEffort',
2131
+ 'browser',
2117
2132
  'instruction',
2118
2133
  'permissions',
2119
2134
  ], path);
@@ -2121,7 +2136,8 @@ function snapshotSessionAgent(value, path) {
2121
2136
  key !== 'effort' &&
2122
2137
  key !== 'fastMode' &&
2123
2138
  key !== 'subagentModel' &&
2124
- key !== 'subagentEffort')), path);
2139
+ key !== 'subagentEffort' &&
2140
+ key !== 'browser')), path);
2125
2141
  return {
2126
2142
  adapter: fixed.adapter,
2127
2143
  ...(fixed.instruction === undefined
@@ -2130,6 +2146,7 @@ function snapshotSessionAgent(value, path) {
2130
2146
  ...(fixed.permissions === undefined
2131
2147
  ? {}
2132
2148
  : { permissions: livePermissions(fixed.permissions) }),
2149
+ ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
2133
2150
  model: snapshotTuningSelection(agent.model, `${path}.model`),
2134
2151
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
2135
2152
  ...(agent.fastMode === undefined
@@ -2163,6 +2180,7 @@ function callSettings(agent, tuning = agent) {
2163
2180
  return {
2164
2181
  model: tuning.model,
2165
2182
  effort: tuning.effort,
2183
+ browser: agent.browser ?? false,
2166
2184
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
2167
2185
  ...(tuning.subagentModel === undefined
2168
2186
  ? {}
@@ -2174,6 +2192,9 @@ function callSettings(agent, tuning = agent) {
2174
2192
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
2175
2193
  };
2176
2194
  }
2195
+ function controlCallSettings(agent) {
2196
+ return { ...callSettings(agent), browser: false, mcpServers: {} };
2197
+ }
2177
2198
  function promptIdentity(binding) {
2178
2199
  return binding.model.kind === 'value'
2179
2200
  ? binding.model.value
@@ -2351,6 +2372,7 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
2351
2372
  hostCapabilitiesById.set(entry.id, hostCapability);
2352
2373
  enablementById.set(entry.id, {
2353
2374
  entry,
2375
+ configuredFrom: from,
2354
2376
  artifactSchema,
2355
2377
  command,
2356
2378
  options: validatedOptions,
@@ -2545,6 +2567,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2545
2567
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2546
2568
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
2547
2569
  const continuity = deps.continuity;
2570
+ const resolveAttachments = deps.resolveAttachments;
2571
+ const beginPreparationMedia = deps.beginPreparationMedia;
2572
+ const onTurnAttachments = deps.onTurnAttachments;
2573
+ let nextTurnAttachments = [];
2574
+ let pendingAttachments = [];
2575
+ const mergeAttachments = (...groups) => [...new Map(groups.flat().map((reference) => [reference.assetId, reference])).values()];
2576
+ const attachmentPaths = async (references, signal) => {
2577
+ if (!references?.length)
2578
+ return undefined;
2579
+ if (!resolveAttachments)
2580
+ throw new Error('The session host cannot materialize required attachments');
2581
+ return resolveAttachments(references, signal);
2582
+ };
2548
2583
  const abortPreparation = deps.abortPreparation;
2549
2584
  let pendingHostCapabilities = deps.hostCapabilities;
2550
2585
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
@@ -2866,6 +2901,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2866
2901
  'parentCallId',
2867
2902
  'request',
2868
2903
  'inputs',
2904
+ 'attachments',
2869
2905
  'options',
2870
2906
  'roleBindings',
2871
2907
  'runtime',
@@ -2951,6 +2987,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2951
2987
  ...(parentCallId === undefined ? {} : { parentCallId }),
2952
2988
  ...(frame.request === undefined ? {} : { request: frame.request }),
2953
2989
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs }),
2990
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
2954
2991
  options,
2955
2992
  roleBindings,
2956
2993
  runtime,
@@ -3324,12 +3361,20 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3324
3361
  turnCalls?.add(tracked);
3325
3362
  return tracked;
3326
3363
  };
3327
- const callCaptainQueued = (frame, context, prompt, options, signal) => {
3364
+ const callCaptainQueued = (frame, context, prompt, options, signal, media) => {
3328
3365
  const queued = captainQueue.add(async () => {
3329
3366
  signal.throwIfAborted();
3330
- const result = await trackHostCall(frame, context.callCaptain(prompt, options));
3367
+ const attachments = media?.attachments?.length ? await attachmentPaths(media.attachments, signal) : undefined;
3331
3368
  signal.throwIfAborted();
3332
- return result;
3369
+ const endMedia = media?.preparation ? beginPreparationMedia?.(frame.sessionId) : undefined;
3370
+ try {
3371
+ const result = await trackHostCall(frame, context.callCaptain(prompt, { ...options, ...(attachments === undefined ? {} : { attachments }) }));
3372
+ signal.throwIfAborted();
3373
+ return result;
3374
+ }
3375
+ finally {
3376
+ endMedia?.();
3377
+ }
3333
3378
  });
3334
3379
  return trackHostCall(frame, queued);
3335
3380
  };
@@ -3398,9 +3443,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3398
3443
  !Number.isSafeInteger(trace.turnId) ||
3399
3444
  trace.turnId <= 0 ||
3400
3445
  !Number.isSafeInteger(trace.sequence) ||
3401
- trace.sequence <= 0 ||
3402
- summary === undefined ||
3403
- !summaryIncludes(frame)) {
3446
+ trace.sequence <= 0) {
3447
+ return;
3448
+ }
3449
+ if (trace.type === 'player.call.finished') {
3450
+ const call = payloadRecord(trace.payload);
3451
+ if (typeof trace.callId === 'string' && call?.status === 'ok' &&
3452
+ typeof call.roleId === 'string' && typeof call.playerId === 'string' &&
3453
+ frame.playerBindings.get(call.roleId)?.playerId === call.playerId &&
3454
+ typeof call.stateId === 'string' && typeof call.finalText === 'string') {
3455
+ turn.workerCalls.set(`${frame.sessionId}:${trace.callId}`, { frame, callId: trace.callId, roleId: call.roleId, playerId: call.playerId, stateId: call.stateId, finalText: call.finalText });
3456
+ }
3404
3457
  return;
3405
3458
  }
3406
3459
  if (trace.type !== 'outcome.accepted')
@@ -3419,6 +3472,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3419
3472
  receipt.acceptedOutcome.trim().length === 0) {
3420
3473
  return;
3421
3474
  }
3475
+ const ledger = currentEffectLedger();
3476
+ const previous = turn.progressBase?.effectLedger;
3477
+ for (const call of turn.workerCalls.values()) {
3478
+ if (call.frame !== frame || call.stateId !== receipt.source)
3479
+ continue;
3480
+ const boundary = [...ledger.boundaries].reverse().find((entry) => entry.playbookId === frame.entry.id &&
3481
+ entry.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
3482
+ entry.callId === call.callId && entry.roleId === call.roleId &&
3483
+ entry.sourceStateId === call.stateId && entry.finalText === call.finalText &&
3484
+ !isDeepStrictEqual(entry, previous?.boundaries[entry.sequence - 1]));
3485
+ if (!boundary || turn.workerReports.has(boundary.boundaryId))
3486
+ continue;
3487
+ const report = workerReportExcerpt(boundary, call.playerId, receipt.acceptedOutcome);
3488
+ if (report !== undefined)
3489
+ turn.workerReports.set(boundary.boundaryId, report);
3490
+ }
3491
+ if (summary === undefined || !summaryIncludes(frame))
3492
+ return;
3422
3493
  const traceKey = `${frame.sessionId}:${trace.sequence}`;
3423
3494
  if (summary.acceptedOutcomeTraceKeys.has(traceKey))
3424
3495
  return;
@@ -3438,7 +3509,27 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3438
3509
  const createPorts = (frame) => ({
3439
3510
  ...(recordProgress === undefined ? {} : { recordStep: async (step, position) => {
3440
3511
  journalledFrames.add(frame);
3441
- await saveProgress(step.result === undefined ? { frame, position } : undefined, { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id });
3512
+ const turn = activeTurn;
3513
+ if (step.kind === 'player' && step.result === undefined) {
3514
+ turn?.workerStepStarts.set(step.id, new Set(turn.workerCalls.keys()));
3515
+ }
3516
+ let workerEvidence;
3517
+ const guard = payloadRecord(step.result)?.guard;
3518
+ const before = turn?.workerStepStarts.get(step.id);
3519
+ if (step.kind === 'player' && typeof guard === 'string' && before !== undefined && turn) {
3520
+ const candidates = [...turn.workerCalls.entries()].flatMap(([key, call]) => {
3521
+ if (before.has(key) || call.frame !== frame || call.stateId !== step.stateId)
3522
+ return [];
3523
+ return currentEffectLedger().boundaries.filter((boundary) => boundary.playbookId === frame.entry.id && boundary.sourceStateId === step.stateId &&
3524
+ boundary.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
3525
+ boundary.callId === call.callId && boundary.roleId === call.roleId && boundary.finalText === call.finalText &&
3526
+ !isDeepStrictEqual(boundary, turn.progressBase?.effectLedger.boundaries[boundary.sequence - 1]) &&
3527
+ workerReportExcerpt(boundary, call.playerId, guard) !== undefined).map((boundary) => ({ boundaryId: boundary.boundaryId, playerId: call.playerId }));
3528
+ });
3529
+ if (candidates.length === 1)
3530
+ workerEvidence = candidates[0];
3531
+ }
3532
+ await saveProgress(step.result === undefined ? { frame, position } : undefined, { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id, ...(workerEvidence === undefined ? {} : { workerEvidence }) });
3442
3533
  } }),
3443
3534
  callPlayer: async (roleId, prompt, signal, options) => {
3444
3535
  admitHostBoundary();
@@ -3465,6 +3556,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3465
3556
  throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
3466
3557
  }
3467
3558
  const settings = callSettings(binding.agent, binding);
3559
+ const attachments = await attachmentPaths(frame.attachments, signal);
3468
3560
  const calling = {
3469
3561
  phase: 'calling',
3470
3562
  frame,
@@ -3489,7 +3581,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3489
3581
  const call = async (resume) => {
3490
3582
  await continuity?.beforeCall(binding.playerId);
3491
3583
  signal.throwIfAborted();
3492
- const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings })));
3584
+ const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings, ...(attachments === undefined ? {} : { attachments }) })));
3493
3585
  hostResolved = true;
3494
3586
  return raw;
3495
3587
  };
@@ -3611,8 +3703,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3611
3703
  const result = await callCaptainQueued(frame, activeContext, prompt, {
3612
3704
  visibility: options.visibility,
3613
3705
  resume: options.resume,
3706
+ settings: options.allowedTools?.length === 0 ? controlCallSettings(captainAgent) : callSettings(captainAgent),
3614
3707
  ...forwardedToolOptions(options.allowedTools, captainAdapter),
3615
- }, signal);
3708
+ }, signal, { attachments: options.allowedTools?.length === 0 ? undefined : frame.attachments });
3616
3709
  const output = {
3617
3710
  status: result.status,
3618
3711
  ...(result.finalText !== undefined
@@ -3630,6 +3723,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3630
3723
  throw new Error('callJudge invoked outside a Boss turn');
3631
3724
  }
3632
3725
  const result = await callCaptainQueued(frame, activeContext, hiddenJudgeEnvelope(prompt), {
3726
+ settings: controlCallSettings(captainAgent),
3633
3727
  visibility: 'hidden',
3634
3728
  resume: false,
3635
3729
  ...controlCallToolOptions(captainAdapter),
@@ -3758,12 +3852,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3758
3852
  const sessionId = allocateSessionId();
3759
3853
  const playerBindings = makePlayerBindings(enablement);
3760
3854
  const runtime = createRuntimeForEnablement(enablement, hostCapabilitiesById);
3855
+ // A nested child inherits only its parent's selection, never the turn's
3856
+ // unselected references; only a root reads the turn's selection.
3857
+ const inherited = parent ? parent.frame.attachments : (activeTurn?.selectedAttachments ?? activeTurn?.attachments);
3761
3858
  return {
3762
3859
  entry,
3763
3860
  enablement,
3764
3861
  runtime,
3765
3862
  sessionId,
3766
3863
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
3864
+ ...(inherited?.length ? { attachments: [...inherited] } : {}),
3767
3865
  depth: parent ? parent.frame.depth + 1 : 0,
3768
3866
  playerBindings,
3769
3867
  ...(parent ? { parent } : {}),
@@ -3779,12 +3877,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3779
3877
  enablement,
3780
3878
  runtime,
3781
3879
  sessionId: snapshot.sessionId,
3880
+ effectOwnerSessionId: snapshot.runtime.retainedEffectSourceSessionId ?? snapshot.sessionId,
3782
3881
  rootSessionId: snapshot.rootSessionId,
3783
3882
  depth: snapshot.depth,
3784
3883
  playerBindings,
3785
3884
  ...(parent ? { parent } : {}),
3786
3885
  ...(snapshot.request === undefined ? {} : { request: snapshot.request }),
3787
3886
  ...(snapshot.inputs === undefined ? {} : { inputs: [...snapshot.inputs] }),
3887
+ ...(snapshot.attachments === undefined ? {} : { attachments: [...snapshot.attachments] }),
3788
3888
  state: snapshot.runtime.state,
3789
3889
  inFlightHostCalls: new Set(),
3790
3890
  };
@@ -4452,6 +4552,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4452
4552
  return;
4453
4553
  }
4454
4554
  assertRetainableResult(frame, result);
4555
+ // A deferred checkpoint refusal changes the runtime's control overlay
4556
+ // without publishing an FSM transition. Refresh its derived question
4557
+ // mirror from an exact safe snapshot before reporting or saving the stop.
4558
+ if (leafFrame() === frame && !frame.disposing &&
4559
+ retainedEffectReconciliation === undefined) {
4560
+ try {
4561
+ const exported = frame.runtime.exportSnapshot?.();
4562
+ if (exported !== undefined) {
4563
+ const snapshot = assertPlaybookRuntimeSnapshot(exported, frame.entry.id);
4564
+ if (isDeepStrictEqual(snapshot.state, result.state) &&
4565
+ isDeepStrictEqual(snapshot.effectLedger, currentEffectLedger())) {
4566
+ pendingBossQuestions = snapshot.pendingBossQuestions.length === 0 && pendingBossQuestions === undefined
4567
+ ? undefined : mirroredBossQuestions(snapshot.pendingBossQuestions);
4568
+ }
4569
+ }
4570
+ }
4571
+ catch { /* An inconsistent runtime must still fail exact capture. */ }
4572
+ }
4455
4573
  if (result.outcome === 'aborted' && runFailureFacts) {
4456
4574
  runFailureFacts.push(`${frameLabel(frame)} was aborted before its outcome could be confirmed; it was not repeated automatically.`);
4457
4575
  }
@@ -4521,6 +4639,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4521
4639
  request.playbookId,
4522
4640
  ].join(' -> ')}`);
4523
4641
  }
4642
+ const reviewRefusal = missingPackagedReview(enablementById, entry.id);
4643
+ if (reviewRefusal !== undefined)
4644
+ throw new Error(reviewRefusal);
4524
4645
  pendingChildParents.add(parent);
4525
4646
  let child;
4526
4647
  try {
@@ -5328,11 +5449,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5328
5449
  await continuity?.beforeCall('captain');
5329
5450
  context.signal.throwIfAborted();
5330
5451
  attempt.providerBoundaryEntered = true;
5331
- const result = await classifySettingsCall(() => context.callCaptain(prompt, {
5452
+ 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
5453
  visibility: 'hidden',
5333
5454
  resume,
5334
5455
  ...controlCallToolOptions(captainAdapter),
5335
- settings: callSettings(captainAgent),
5456
+ settings: controlCallSettings(captainAgent),
5336
5457
  }));
5337
5458
  context.signal.throwIfAborted();
5338
5459
  return result;
@@ -5497,6 +5618,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5497
5618
  ...(kind === 'decision'
5498
5619
  ? [labeledBlock('Catalog digest', catalogDigest())]
5499
5620
  : []),
5621
+ ...(kind === 'closingReply' && turn?.workerReports.size
5622
+ ? [workerReportBlock([...turn.workerReports.values()])]
5623
+ : []),
5500
5624
  ...(kind === 'closingReply' && turn?.report
5501
5625
  ? [
5502
5626
  labeledBlock('ControlView digest', controlViewDigest()),
@@ -5821,6 +5945,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5821
5945
  enablement,
5822
5946
  runtime: offer.runtimes[index],
5823
5947
  sessionId: targetSessionIds[index],
5948
+ effectOwnerSessionId: sourceFrame.runtime.retainedEffectSourceSessionId ?? sourceFrame.sessionId,
5824
5949
  rootSessionId: targetRootSessionId,
5825
5950
  depth: index,
5826
5951
  playerBindings: makePlayerBindings(enablement),
@@ -5829,6 +5954,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5829
5954
  : {}),
5830
5955
  ...(sourceFrame.request === undefined ? {} : { request: sourceFrame.request }),
5831
5956
  ...(sourceFrame.inputs === undefined ? {} : { inputs: [...sourceFrame.inputs] }),
5957
+ ...(sourceFrame.attachments === undefined ? {} : { attachments: [...sourceFrame.attachments] }),
5832
5958
  state: sourceFrame.runtime.state,
5833
5959
  inFlightHostCalls: new Set(),
5834
5960
  });
@@ -6151,7 +6277,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6151
6277
  // selections freeze after their work and before reporting begins.
6152
6278
  if (selection.action === 'respond')
6153
6279
  freezeControllerEvidence();
6154
- let settlement = await executeSelection(selection, signal, automaticRecovery);
6280
+ signal.throwIfAborted();
6281
+ const reviewRefusal = selection.action === 'start' || selection.action === 'switch'
6282
+ ? missingPackagedReview(enablementById, selection.playbookId)
6283
+ : undefined;
6284
+ let settlement = reviewRefusal === undefined
6285
+ ? await executeSelection(selection, signal, automaticRecovery)
6286
+ : await rejectSelection(selection, reviewRefusal);
6155
6287
  // One controller action can include bounded prerequisite recovery.
6156
6288
  // Neither the model nor a retry manufactures an unadvertised transition.
6157
6289
  if (turn && abortPreparation && settlement.status !== 'rejected' && !['respond', 'dismiss', 'recover'].includes(selection.action)) {
@@ -6383,6 +6515,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6383
6515
  return rejectSelection(selection, `/${enablementById.get(entry.id).command} is already on the active path`);
6384
6516
  }
6385
6517
  }
6518
+ const selectedIds = selection.attachmentIds;
6519
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leafFrame()?.attachments ?? []).map((reference) => [reference.assetId, reference]));
6520
+ if (selectedIds?.some((id) => !available.has(id)) || (selectedIds && new Set(selectedIds).size !== selectedIds.length))
6521
+ return rejectSelection(selection, 'the request selects unavailable attachment evidence');
6522
+ turn.selectedAttachments = selectedIds === undefined ? turn.attachments : selectedIds.map((id) => available.get(id));
6523
+ pendingAttachments = [];
6386
6524
  turn.settled = true;
6387
6525
  journalAction({
6388
6526
  action: selection.action,
@@ -6515,7 +6653,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6515
6653
  `Later Boss input (context only): ${JSON.stringify(laterInputs)}`,
6516
6654
  `Existing instructions: ${JSON.stringify(instructions)}`,
6517
6655
  `Pending questions (quoted evidence): ${JSON.stringify(before.pendingQuestions.map(({ question }) => question))}`,
6518
- ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: callSettings(captainAgent) }, signal);
6656
+ ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: controlCallSettings(captainAgent) }, signal);
6519
6657
  const answer = result.status === 'ok' ? controlReplyJson(result.finalText) : undefined;
6520
6658
  const index = answer && typeof answer === 'object' && !Array.isArray(answer) && Object.keys(answer).join(',') === 'instructionIndex'
6521
6659
  ? answer.instructionIndex : undefined;
@@ -6573,7 +6711,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6573
6711
  try {
6574
6712
  claim = await capability.acquire({ signal: preparationSignal });
6575
6713
  await claim.assertOwner();
6576
- const result = await runEffect(() => callCaptainQueued(leaf, context, preparationPrompt, { visibility: 'hidden', resume: false, settings: callSettings(captainAgent) }, preparationSignal));
6714
+ const result = await runEffect(() => callCaptainQueued(leaf, context, preparationPrompt, { visibility: 'hidden', resume: false, settings: callSettings(captainAgent) }, preparationSignal, { attachments: leaf.attachments, preparation: true }));
6577
6715
  preparationSignal.throwIfAborted();
6578
6716
  await claim.assertOwner();
6579
6717
  if (result.status !== 'ok') {
@@ -6616,6 +6754,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6616
6754
  : { action: 'runtime', actionId: offer.continuation.actionId };
6617
6755
  }
6618
6756
  if (selection.action === 'deliver') {
6757
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leaf.attachments ?? []).map((reference) => [reference.assetId, reference]));
6758
+ if (selection.attachmentIds?.some((id) => !available.has(id)) || (selection.attachmentIds && new Set(selection.attachmentIds).size !== selection.attachmentIds.length))
6759
+ return rejectSelection(selection, 'the request selects unavailable attachment evidence');
6760
+ const selected = selection.attachmentIds === undefined ? turn.attachments : selection.attachmentIds.map((id) => available.get(id));
6761
+ const nextAttachments = mergeAttachments(leaf.attachments ?? [], selected);
6762
+ if (nextAttachments.length)
6763
+ leaf.attachments = nextAttachments;
6764
+ // Delivered references now belong to the leaf; the rest stay pending.
6765
+ const delivered = new Set(selected.map((reference) => reference.assetId));
6766
+ pendingAttachments = pendingAttachments.filter((reference) => !delivered.has(reference.assetId));
6619
6767
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
6620
6768
  // for that text — any text carried on the selection is ignored.
6621
6769
  turn.settled = true;
@@ -7154,6 +7302,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7154
7302
  : {}),
7155
7303
  ...(frame.request === undefined ? {} : { request: frame.request }),
7156
7304
  ...(frame.inputs === undefined ? {} : { inputs: [...frame.inputs] }),
7305
+ ...(frame.attachments === undefined ? {} : { attachments: [...frame.attachments] }),
7157
7306
  options: frame.enablement.options,
7158
7307
  roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
7159
7308
  role,
@@ -7365,6 +7514,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7365
7514
  issuedSessionIds: [...issuedSessionIds],
7366
7515
  sequences: { turn: turnSequence, journal: journalSeq },
7367
7516
  journal,
7517
+ ...(pendingAttachments.length ? { pendingAttachments } : {}),
7368
7518
  ...(lastAction === undefined ? {} : { lastAction }),
7369
7519
  ...(lastSettlementStatus === undefined
7370
7520
  ? {}
@@ -7659,6 +7809,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7659
7809
  for (const id of snapshot.issuedSessionIds)
7660
7810
  issuedSessionIds.add(id);
7661
7811
  journal.push(...snapshot.journal);
7812
+ pendingAttachments = [...(snapshot.pendingAttachments ?? [])];
7662
7813
  journalSeq = snapshot.sequences.journal;
7663
7814
  turnSequence = snapshot.sequences.turn;
7664
7815
  conversation = snapshot.captain.conversation;
@@ -7790,10 +7941,29 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7790
7941
  selectInterruptedReport(input, report) {
7791
7942
  if (activeTurn)
7792
7943
  throw new Error('A Captain turn is already active');
7793
- if (!input.trim() || !report.text.trim())
7944
+ if ((!input.trim() && nextTurnAttachments.length === 0) || !report.text.trim())
7794
7945
  throw new Error('Interruption report requires its recorded input and explanation');
7795
7946
  pendingHostSelection = { kind: 'report', text: input, report };
7796
7947
  },
7948
+ recordMediaEvidence(reference, runtimeSessionId) {
7949
+ const asset = validateAssetRef(reference);
7950
+ if (!activeTurn)
7951
+ throw new Error('Media evidence arrived outside an active turn');
7952
+ activeTurn.evidence = mergeAttachments(activeTurn.evidence ?? [], [asset]);
7953
+ const producingIndex = frames.findIndex((candidate) => candidate.sessionId === runtimeSessionId);
7954
+ // Observed output belongs to this engagement's reasoning context: a
7955
+ // resumed parent may inspect its child's figure. Boss input additions
7956
+ // still target only the addressed leaf, and a new root inherits none.
7957
+ for (const frame of frames.slice(0, producingIndex + 1)) {
7958
+ frame.attachments = mergeAttachments(frame.attachments ?? [], [asset]);
7959
+ }
7960
+ },
7961
+ setTurnAttachments(references) {
7962
+ if (activeTurn || activeTurnHostCalls)
7963
+ throw new Error('A Captain turn is already active');
7964
+ nextTurnAttachments = validateAssetRefs(references);
7965
+ onTurnAttachments?.(nextTurnAttachments);
7966
+ },
7797
7967
  async handleBossTurn(turn, context) {
7798
7968
  if (lifecycle !== 'ready' || terminallyDisposed) {
7799
7969
  throw new Error('init must be called first, or restore must complete before handling a Boss turn');
@@ -7812,7 +7982,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7812
7982
  // telemetry (CAPTAIN-7).
7813
7983
  retentionSettlementReady = false;
7814
7984
  abandonmentSettlementUnsafe = false;
7815
- if (turn.prompt.trim().length === 0)
7985
+ const attachments = nextTurnAttachments;
7986
+ nextTurnAttachments = [];
7987
+ if (turn.prompt.trim().length === 0 && attachments.length === 0)
7816
7988
  return;
7817
7989
  const progressBase = exportShellSnapshot();
7818
7990
  settledTurnUnresolvedEffects = undefined;
@@ -7840,6 +8012,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7840
8012
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
7841
8013
  const parsed = decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
7842
8014
  activeTurn = {
8015
+ attachments,
8016
+ workerReports: new Map(),
8017
+ workerStepStarts: new Map(),
8018
+ workerCalls: new Map(),
7843
8019
  appliedActionCount: 0,
7844
8020
  stoppedFrames: new Set(),
7845
8021
  ...(progressBase === undefined ? {} : { progressBase }),
@@ -7873,7 +8049,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7873
8049
  }
7874
8050
  }
7875
8051
  decisionCall = undefined;
7876
- appendJournal('boss', turn.prompt);
8052
+ appendJournal('boss', attachments.length ? { text: turn.prompt, attachments } : turn.prompt);
8053
+ pendingAttachments = mergeAttachments(pendingAttachments, attachments);
7877
8054
  try {
7878
8055
  await drainRetiredRetainedRuntimes();
7879
8056
  await prepareRetainedGenerationOffers();
@@ -7882,6 +8059,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
7882
8059
  await settleSelection({ action: 'respond', text: activeTurn.interruptedReport.text }, context.signal);
7883
8060
  return;
7884
8061
  }
8062
+ if (!turn.prompt.trim() && attachments.length) {
8063
+ markConversationCatchUp();
8064
+ await settleSelection({ action: 'respond', text: 'What would you like me to do with the attached material?' }, context.signal);
8065
+ return;
8066
+ }
7885
8067
  const result = await captainRuntime.handleBossInput({
7886
8068
  text: turn.prompt,
7887
8069
  signal: context.signal,