@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
@@ -61,8 +61,14 @@ import createDefaultCaptainRuntime, {
61
61
  type SettlementEvidence,
62
62
  } from '../captain.playbook/captain.playbook.js';
63
63
  import type { PlaybookSummaryPolicy } from './code.registry.js';
64
+ import type { Attachment } from '@sublang/cligent';
65
+ import { validateAssetRef, validateAssetRefs } from './session-assets.js';
66
+ import type { SessionAssetRef } from './session-assets.js';
67
+ import { QUOTED_EVIDENCE_LIMIT, workerReportExcerpt, workerReportBlock } from './worker-evidence.js';
64
68
 
65
69
  interface SessionAgent {
70
+ /** Mutable next-turn execution capability; never structural identity. */
71
+ readonly browser?: boolean;
66
72
  readonly adapter: string;
67
73
  readonly model: TuningSelection;
68
74
  readonly effort: TuningSelection<Effort>;
@@ -121,13 +127,13 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
121
127
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
122
128
  }
123
129
 
124
- type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey>>;
130
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey | 'browser'>>;
125
131
 
126
132
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
127
133
 
128
134
  export interface ProgressChange {
129
135
  snapshot?: PlaybookCaptainShellSnapshot | null;
130
- step?: Omit<PlaybookStepRecord, 'kind'> & { kind: PlaybookStepRecord['kind'] | 'preparation' | 'completion' | 'answer'; runtimeSessionId: string; playbookId: string };
136
+ step?: Omit<PlaybookStepRecord, 'kind'> & { kind: PlaybookStepRecord['kind'] | 'preparation' | 'completion' | 'answer'; runtimeSessionId: string; playbookId: string; workerEvidence?: { boundaryId: string; playerId: string } };
131
137
  }
132
138
  export interface InterruptedReport {
133
139
  text: string;
@@ -137,6 +143,9 @@ export interface InterruptedReport {
137
143
  }
138
144
 
139
145
  export interface PlaybookCaptainDeps {
146
+ onTurnAttachments?: (references: readonly SessionAssetRef[]) => void;
147
+ beginPreparationMedia?: (runtimeSessionId: string) => () => void;
148
+ resolveAttachments?: (references: readonly SessionAssetRef[], signal: AbortSignal) => Promise<readonly Attachment[]>;
140
149
  /** Stop the host's active turn, including admitted tool calls, on preparation expiry or a required save failure. */
141
150
  abortPreparation?: (reason?: string) => void;
142
151
  recordProgress?: (change: ProgressChange) => Promise<void>;
@@ -259,6 +268,7 @@ export interface PlaybookCaptainFrameSnapshot {
259
268
  readonly parentCallId?: string;
260
269
  readonly request?: string;
261
270
  readonly inputs?: readonly string[];
271
+ readonly attachments?: readonly SessionAssetRef[];
262
272
  readonly options: JsonValue;
263
273
  readonly roleBindings: Readonly<Record<string, string>>;
264
274
  readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
@@ -282,6 +292,7 @@ interface PlaybookCaptainShellSnapshotFields {
282
292
  readonly journal: number;
283
293
  };
284
294
  readonly journal: readonly PlaybookCaptainJournalRecord[];
295
+ readonly pendingAttachments?: readonly SessionAssetRef[];
285
296
  readonly lastAction?:
286
297
  | 'respond'
287
298
  | 'start'
@@ -388,6 +399,9 @@ export interface PlaybookCaptainSettlement {
388
399
 
389
400
  /** tmux and headless front ends share this one durable Captain shell API. */
390
401
  export interface PlaybookCaptainShell extends Captain {
402
+ /** Bind already-owned evidence to the next visible Boss turn without changing its text. */
403
+ setTurnAttachments(references: readonly SessionAssetRef[]): void;
404
+ recordMediaEvidence(reference: SessionAssetRef, runtimeSessionId?: string): void;
391
405
  installRetainedGenerations(
392
406
  generations: Readonly<
393
407
  Record<string, PlaybookCaptainRetainedGeneration>
@@ -488,6 +502,8 @@ type PlayerTransaction =
488
502
  });
489
503
 
490
504
  interface EngagementFrame {
505
+ effectOwnerSessionId?: string;
506
+ attachments?: readonly SessionAssetRef[];
491
507
  request?: string;
492
508
  inputs?: string[];
493
509
  pauseCancellation?: () => () => void;
@@ -677,6 +693,12 @@ interface ActiveTurnSummary {
677
693
 
678
694
  /** The shell state of one Boss turn (DR-029). */
679
695
  interface ActiveTurn {
696
+ readonly workerReports: Map<string, string>;
697
+ readonly workerStepStarts: Map<string, ReadonlySet<string>>;
698
+ readonly workerCalls: Map<string, { frame: EngagementFrame; callId: string; roleId: string; playerId: string; stateId: string; finalText: string }>;
699
+ evidence?: readonly SessionAssetRef[];
700
+ attachments: readonly SessionAssetRef[];
701
+ selectedAttachments?: readonly SessionAssetRef[];
680
702
  appliedActionCount: number;
681
703
  startedEngagement?: boolean;
682
704
  countedSummary?: ActiveTurnSummary;
@@ -847,7 +869,6 @@ function quoteEvidence(text: string): string {
847
869
  // into a fact and still knows it is foreign — never at the digest renderer,
848
870
  // which sees an opaque payload and cannot tell quoted output from the Boss
849
871
  // text, captain speech, or settlement facts the shell authored itself.
850
- const QUOTED_EVIDENCE_LIMIT = 400;
851
872
 
852
873
  // The same guard for strings the shell interpolates into a single-line fact:
853
874
  // control characters collapse to spaces so a quoted message can never open a
@@ -3011,6 +3032,7 @@ export function assertPlaybookCaptainShellSnapshot(
3011
3032
  'issuedSessionIds',
3012
3033
  'sequences',
3013
3034
  'journal',
3035
+ 'pendingAttachments',
3014
3036
  'lastAction',
3015
3037
  'lastSettlementStatus',
3016
3038
  'mode',
@@ -3339,6 +3361,7 @@ export function assertPlaybookCaptainShellSnapshot(
3339
3361
  issuedSessionIds: issued,
3340
3362
  sequences: { turn: turnSequence, journal: journalSequence },
3341
3363
  journal: normalizedJournal,
3364
+ ...(snapshot.pendingAttachments === undefined ? {} : { pendingAttachments: validateAssetRefs(snapshot.pendingAttachments) }),
3342
3365
  ...(lastAction === undefined ? {} : { lastAction }),
3343
3366
  ...(lastSettlementStatus === undefined
3344
3367
  ? {}
@@ -3390,6 +3413,7 @@ export function assertPlaybookCaptainShellSnapshot(
3390
3413
  'parentCallId',
3391
3414
  'request',
3392
3415
  'inputs',
3416
+ 'attachments',
3393
3417
  'options',
3394
3418
  'roleBindings',
3395
3419
  'runtime',
@@ -3447,6 +3471,7 @@ export function assertPlaybookCaptainShellSnapshot(
3447
3471
  ...(parentCallId === undefined ? {} : { parentCallId }),
3448
3472
  ...(frame.request === undefined ? {} : { request: frame.request as string }),
3449
3473
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs as string[] }),
3474
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
3450
3475
  options,
3451
3476
  roleBindings,
3452
3477
  runtime,
@@ -3763,6 +3788,7 @@ function snapshotSessionAgent(
3763
3788
  'fastMode',
3764
3789
  'subagentModel',
3765
3790
  'subagentEffort',
3791
+ 'browser',
3766
3792
  'instruction',
3767
3793
  'permissions',
3768
3794
  ],
@@ -3776,7 +3802,8 @@ function snapshotSessionAgent(
3776
3802
  key !== 'effort' &&
3777
3803
  key !== 'fastMode' &&
3778
3804
  key !== 'subagentModel' &&
3779
- key !== 'subagentEffort',
3805
+ key !== 'subagentEffort' &&
3806
+ key !== 'browser',
3780
3807
  ),
3781
3808
  ) as JsonValue,
3782
3809
  path,
@@ -3789,6 +3816,7 @@ function snapshotSessionAgent(
3789
3816
  ...(fixed.permissions === undefined
3790
3817
  ? {}
3791
3818
  : { permissions: livePermissions(fixed.permissions) }),
3819
+ ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
3792
3820
  model: snapshotTuningSelection(agent.model, `${path}.model`),
3793
3821
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
3794
3822
  ...(agent.fastMode === undefined
@@ -3813,7 +3841,7 @@ function snapshotSessionAgent(
3813
3841
  };
3814
3842
  }
3815
3843
 
3816
- function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey> {
3844
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey | 'browser'> {
3817
3845
  return {
3818
3846
  adapter: agent.adapter,
3819
3847
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
@@ -3833,6 +3861,7 @@ function callSettings(
3833
3861
  return {
3834
3862
  model: tuning.model,
3835
3863
  effort: tuning.effort,
3864
+ browser: agent.browser ?? false,
3836
3865
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
3837
3866
  ...(tuning.subagentModel === undefined
3838
3867
  ? {}
@@ -3845,6 +3874,10 @@ function callSettings(
3845
3874
  };
3846
3875
  }
3847
3876
 
3877
+ function controlCallSettings(agent: SessionAgent): AgentCallSettings {
3878
+ return { ...callSettings(agent), browser: false, mcpServers: {} };
3879
+ }
3880
+
3848
3881
  function promptIdentity(binding: EffectivePlayerBinding): string {
3849
3882
  return binding.model.kind === 'value'
3850
3883
  ? binding.model.value
@@ -4421,6 +4454,17 @@ export function createPlaybookCaptainShell(
4421
4454
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
4422
4455
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
4423
4456
  const continuity = deps.continuity;
4457
+ const resolveAttachments = deps.resolveAttachments;
4458
+ const beginPreparationMedia = deps.beginPreparationMedia;
4459
+ const onTurnAttachments = deps.onTurnAttachments;
4460
+ let nextTurnAttachments: readonly SessionAssetRef[] = [];
4461
+ let pendingAttachments: readonly SessionAssetRef[] = [];
4462
+ const mergeAttachments = (...groups: readonly (readonly SessionAssetRef[])[]) => [...new Map(groups.flat().map((reference) => [reference.assetId, reference])).values()];
4463
+ const attachmentPaths = async (references: readonly SessionAssetRef[] | undefined, signal: AbortSignal) => {
4464
+ if (!references?.length) return undefined;
4465
+ if (!resolveAttachments) throw new Error('The session host cannot materialize required attachments');
4466
+ return resolveAttachments(references, signal);
4467
+ };
4424
4468
  const abortPreparation = deps.abortPreparation;
4425
4469
  let pendingHostCapabilities = deps.hostCapabilities;
4426
4470
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
@@ -4879,6 +4923,7 @@ export function createPlaybookCaptainShell(
4879
4923
  'parentCallId',
4880
4924
  'request',
4881
4925
  'inputs',
4926
+ 'attachments',
4882
4927
  'options',
4883
4928
  'roleBindings',
4884
4929
  'runtime',
@@ -5028,6 +5073,7 @@ export function createPlaybookCaptainShell(
5028
5073
  ...(parentCallId === undefined ? {} : { parentCallId }),
5029
5074
  ...(frame.request === undefined ? {} : { request: frame.request as string }),
5030
5075
  ...(frame.inputs === undefined ? {} : { inputs: frame.inputs as string[] }),
5076
+ ...(frame.attachments === undefined ? {} : { attachments: validateAssetRefs(frame.attachments) }),
5031
5077
  options,
5032
5078
  roleBindings,
5033
5079
  runtime,
@@ -5527,15 +5573,21 @@ export function createPlaybookCaptainShell(
5527
5573
  prompt: string,
5528
5574
  options: Parameters<CaptainContext['callCaptain']>[1],
5529
5575
  signal: AbortSignal,
5576
+ media?: { readonly attachments?: readonly SessionAssetRef[]; readonly preparation?: true },
5530
5577
  ): ReturnType<CaptainContext['callCaptain']> => {
5531
5578
  const queued = captainQueue.add(async () => {
5532
5579
  signal.throwIfAborted();
5533
- const result = await trackHostCall(
5534
- frame,
5535
- context.callCaptain(prompt, options),
5536
- );
5580
+ const attachments = media?.attachments?.length ? await attachmentPaths(media.attachments, signal) : undefined;
5537
5581
  signal.throwIfAborted();
5538
- return result;
5582
+ const endMedia = media?.preparation ? beginPreparationMedia?.(frame.sessionId) : undefined;
5583
+ try {
5584
+ const result = await trackHostCall(
5585
+ frame,
5586
+ context.callCaptain(prompt, { ...options, ...(attachments === undefined ? {} : { attachments }) }),
5587
+ );
5588
+ signal.throwIfAborted();
5589
+ return result;
5590
+ } finally { endMedia?.(); }
5539
5591
  });
5540
5592
  return trackHostCall(frame, queued);
5541
5593
  };
@@ -5621,12 +5673,20 @@ export function createPlaybookCaptainShell(
5621
5673
  !Number.isSafeInteger(trace.turnId) ||
5622
5674
  (trace.turnId as number) <= 0 ||
5623
5675
  !Number.isSafeInteger(trace.sequence) ||
5624
- (trace.sequence as number) <= 0 ||
5625
- summary === undefined ||
5626
- !summaryIncludes(frame)
5676
+ (trace.sequence as number) <= 0
5627
5677
  ) {
5628
5678
  return;
5629
5679
  }
5680
+ if (trace.type === 'player.call.finished') {
5681
+ const call = payloadRecord(trace.payload);
5682
+ if (typeof trace.callId === 'string' && call?.status === 'ok' &&
5683
+ typeof call.roleId === 'string' && typeof call.playerId === 'string' &&
5684
+ frame.playerBindings.get(call.roleId)?.playerId === call.playerId &&
5685
+ typeof call.stateId === 'string' && typeof call.finalText === 'string') {
5686
+ turn.workerCalls.set(`${frame.sessionId}:${trace.callId}`, { frame, callId: trace.callId, roleId: call.roleId, playerId: call.playerId, stateId: call.stateId, finalText: call.finalText });
5687
+ }
5688
+ return;
5689
+ }
5630
5690
  if (trace.type !== 'outcome.accepted') return;
5631
5691
  const receipt = exactOwnDataRecord(trace.payload, [
5632
5692
  'source',
@@ -5644,6 +5704,21 @@ export function createPlaybookCaptainShell(
5644
5704
  ) {
5645
5705
  return;
5646
5706
  }
5707
+ const ledger = currentEffectLedger();
5708
+ const previous = turn.progressBase?.effectLedger;
5709
+ for (const call of turn.workerCalls.values()) {
5710
+ if (call.frame !== frame || call.stateId !== receipt.source) continue;
5711
+ const boundary = [...ledger.boundaries].reverse().find((entry) =>
5712
+ entry.playbookId === frame.entry.id &&
5713
+ entry.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
5714
+ entry.callId === call.callId && entry.roleId === call.roleId &&
5715
+ entry.sourceStateId === call.stateId && entry.finalText === call.finalText &&
5716
+ !isDeepStrictEqual(entry, previous?.boundaries[entry.sequence - 1]));
5717
+ if (!boundary || turn.workerReports.has(boundary.boundaryId)) continue;
5718
+ const report = workerReportExcerpt(boundary, call.playerId, receipt.acceptedOutcome);
5719
+ if (report !== undefined) turn.workerReports.set(boundary.boundaryId, report);
5720
+ }
5721
+ if (summary === undefined || !summaryIncludes(frame)) return;
5647
5722
  const traceKey = `${frame.sessionId}:${trace.sequence}`;
5648
5723
  if (summary.acceptedOutcomeTraceKeys.has(traceKey)) return;
5649
5724
  summary.acceptedOutcomeTraceKeys.add(traceKey);
@@ -5671,8 +5746,28 @@ export function createPlaybookCaptainShell(
5671
5746
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
5672
5747
  ...(recordProgress === undefined ? {} : { recordStep: async (step: PlaybookStepRecord, position?: PlaybookRuntimeSnapshot) => {
5673
5748
  journalledFrames.add(frame);
5749
+ const turn = activeTurn;
5750
+ if (step.kind === 'player' && step.result === undefined) {
5751
+ turn?.workerStepStarts.set(step.id, new Set(turn.workerCalls.keys()));
5752
+ }
5753
+ let workerEvidence: { boundaryId: string; playerId: string } | undefined;
5754
+ const guard = payloadRecord(step.result)?.guard;
5755
+ const before = turn?.workerStepStarts.get(step.id);
5756
+ if (step.kind === 'player' && typeof guard === 'string' && before !== undefined && turn) {
5757
+ const candidates = [...turn.workerCalls.entries()].flatMap(([key, call]) => {
5758
+ if (before.has(key) || call.frame !== frame || call.stateId !== step.stateId) return [];
5759
+ return currentEffectLedger().boundaries.filter((boundary) =>
5760
+ boundary.playbookId === frame.entry.id && boundary.sourceStateId === step.stateId &&
5761
+ boundary.runtimeSessionId === (frame.effectOwnerSessionId ?? frame.sessionId) &&
5762
+ boundary.callId === call.callId && boundary.roleId === call.roleId && boundary.finalText === call.finalText &&
5763
+ !isDeepStrictEqual(boundary, turn.progressBase?.effectLedger.boundaries[boundary.sequence - 1]) &&
5764
+ workerReportExcerpt(boundary, call.playerId, guard) !== undefined
5765
+ ).map((boundary) => ({ boundaryId: boundary.boundaryId, playerId: call.playerId }));
5766
+ });
5767
+ if (candidates.length === 1) workerEvidence = candidates[0];
5768
+ }
5674
5769
  await saveProgress(step.result === undefined ? { frame, position } : undefined,
5675
- { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id });
5770
+ { ...step, runtimeSessionId: frame.sessionId, playbookId: frame.entry.id, ...(workerEvidence === undefined ? {} : { workerEvidence }) });
5676
5771
  } }),
5677
5772
  callPlayer: async (roleId, prompt, signal, options) => {
5678
5773
  admitHostBoundary();
@@ -5705,6 +5800,7 @@ export function createPlaybookCaptainShell(
5705
5800
  );
5706
5801
  }
5707
5802
  const settings = callSettings(binding.agent, binding);
5803
+ const attachments = await attachmentPaths(frame.attachments, signal);
5708
5804
  const calling: PlayerTransaction = {
5709
5805
  phase: 'calling',
5710
5806
  frame,
@@ -5731,7 +5827,7 @@ export function createPlaybookCaptainShell(
5731
5827
  const raw = await trackHostCall(
5732
5828
  frame,
5733
5829
  classifySettingsCall(() =>
5734
- context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings }),
5830
+ context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings, ...(attachments === undefined ? {} : { attachments }) }),
5735
5831
  ),
5736
5832
  );
5737
5833
  hostResolved = true;
@@ -5862,9 +5958,11 @@ export function createPlaybookCaptainShell(
5862
5958
  {
5863
5959
  visibility: options.visibility,
5864
5960
  resume: options.resume,
5961
+ settings: options.allowedTools?.length === 0 ? controlCallSettings(captainAgent!) : callSettings(captainAgent!),
5865
5962
  ...forwardedToolOptions(options.allowedTools, captainAdapter),
5866
5963
  },
5867
5964
  signal,
5965
+ { attachments: options.allowedTools?.length === 0 ? undefined : frame.attachments },
5868
5966
  );
5869
5967
  const output = {
5870
5968
  status: result.status,
@@ -5886,6 +5984,7 @@ export function createPlaybookCaptainShell(
5886
5984
  activeContext,
5887
5985
  hiddenJudgeEnvelope(prompt),
5888
5986
  {
5987
+ settings: controlCallSettings(captainAgent!),
5889
5988
  visibility: 'hidden',
5890
5989
  resume: false,
5891
5990
  ...controlCallToolOptions(captainAdapter),
@@ -6049,6 +6148,7 @@ export function createPlaybookCaptainShell(
6049
6148
  runtime,
6050
6149
  sessionId,
6051
6150
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
6151
+ ...((parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn?.attachments)?.length ? { attachments: [...(parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn!.attachments)] } : {}),
6052
6152
  depth: parent ? parent.frame.depth + 1 : 0,
6053
6153
  playerBindings,
6054
6154
  ...(parent ? { parent } : {}),
@@ -6072,12 +6172,14 @@ export function createPlaybookCaptainShell(
6072
6172
  enablement,
6073
6173
  runtime,
6074
6174
  sessionId: snapshot.sessionId,
6175
+ effectOwnerSessionId: snapshot.runtime.retainedEffectSourceSessionId ?? snapshot.sessionId,
6075
6176
  rootSessionId: snapshot.rootSessionId,
6076
6177
  depth: snapshot.depth,
6077
6178
  playerBindings,
6078
6179
  ...(parent ? { parent } : {}),
6079
6180
  ...(snapshot.request === undefined ? {} : { request: snapshot.request }),
6080
6181
  ...(snapshot.inputs === undefined ? {} : { inputs: [...snapshot.inputs] }),
6182
+ ...(snapshot.attachments === undefined ? {} : { attachments: [...snapshot.attachments] }),
6081
6183
  state: snapshot.runtime.state,
6082
6184
  inFlightHostCalls: new Set(),
6083
6185
  };
@@ -6384,6 +6486,7 @@ export function createPlaybookCaptainShell(
6384
6486
  } finally {
6385
6487
  if (leafFrame() === frame) {
6386
6488
  frames.pop();
6489
+ if (!frame.parent) pendingAttachments = [];
6387
6490
  if (frame.parent) {
6388
6491
  pendingChildParents.delete(frame.parent.frame);
6389
6492
  }
@@ -7899,11 +8002,11 @@ export function createPlaybookCaptainShell(
7899
8002
  context.signal.throwIfAborted();
7900
8003
  attempt.providerBoundaryEntered = true;
7901
8004
  const result = await classifySettingsCall(() =>
7902
- context.callCaptain(prompt, {
8005
+ context.callCaptain(pendingAttachments.length || activeTurn?.attachments.length || activeTurn?.evidence?.length || leafFrame()?.attachments?.length ? `${prompt}\n\nAttachment context (metadata only; do not claim to have viewed these bytes): ${JSON.stringify({ current: activeTurn?.attachments ?? [], pending: pendingAttachments, engagement: leafFrame()?.attachments ?? [], evidence: activeTurn?.evidence ?? [] })}` : prompt, {
7903
8006
  visibility: 'hidden',
7904
8007
  resume,
7905
8008
  ...controlCallToolOptions(captainAdapter),
7906
- settings: callSettings(captainAgent!),
8009
+ settings: controlCallSettings(captainAgent!),
7907
8010
  }),
7908
8011
  );
7909
8012
  context.signal.throwIfAborted();
@@ -8122,6 +8225,9 @@ export function createPlaybookCaptainShell(
8122
8225
  ...(kind === 'decision'
8123
8226
  ? [labeledBlock('Catalog digest', catalogDigest())]
8124
8227
  : []),
8228
+ ...(kind === 'closingReply' && turn?.workerReports.size
8229
+ ? [workerReportBlock([...turn.workerReports.values()])!]
8230
+ : []),
8125
8231
  ...(kind === 'closingReply' && turn?.report
8126
8232
  ? [
8127
8233
  labeledBlock('ControlView digest', controlViewDigest()),
@@ -8506,6 +8612,7 @@ export function createPlaybookCaptainShell(
8506
8612
  enablement,
8507
8613
  runtime: offer.runtimes[index]!,
8508
8614
  sessionId: targetSessionIds[index]!,
8615
+ effectOwnerSessionId: sourceFrame.runtime.retainedEffectSourceSessionId ?? sourceFrame.sessionId,
8509
8616
  rootSessionId: targetRootSessionId,
8510
8617
  depth: index,
8511
8618
  playerBindings: makePlayerBindings(enablement),
@@ -8514,6 +8621,7 @@ export function createPlaybookCaptainShell(
8514
8621
  : {}),
8515
8622
  ...(sourceFrame.request === undefined ? {} : { request: sourceFrame.request }),
8516
8623
  ...(sourceFrame.inputs === undefined ? {} : { inputs: [...sourceFrame.inputs] }),
8624
+ ...(sourceFrame.attachments === undefined ? {} : { attachments: [...sourceFrame.attachments] }),
8517
8625
  state: sourceFrame.runtime.state,
8518
8626
  inFlightHostCalls: new Set(),
8519
8627
  });
@@ -9177,6 +9285,11 @@ export function createPlaybookCaptainShell(
9177
9285
  );
9178
9286
  }
9179
9287
  }
9288
+ const selectedIds = selection.attachmentIds;
9289
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leafFrame()?.attachments ?? []).map((reference) => [reference.assetId, reference]));
9290
+ if (selectedIds?.some((id) => !available.has(id as SessionAssetRef['assetId'])) || (selectedIds && new Set(selectedIds).size !== selectedIds.length)) return rejectSelection(selection, 'the request selects unavailable attachment evidence');
9291
+ turn.selectedAttachments = selectedIds === undefined ? turn.attachments : selectedIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9292
+ pendingAttachments = [];
9180
9293
  turn.settled = true;
9181
9294
  journalAction({
9182
9295
  action: selection.action,
@@ -9324,7 +9437,7 @@ export function createPlaybookCaptainShell(
9324
9437
  `Later Boss input (context only): ${JSON.stringify(laterInputs)}`,
9325
9438
  `Existing instructions: ${JSON.stringify(instructions)}`,
9326
9439
  `Pending questions (quoted evidence): ${JSON.stringify(before.pendingQuestions.map(({ question }) => question))}`,
9327
- ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: callSettings(captainAgent!) }, signal);
9440
+ ].join('\n')), { visibility: 'hidden', resume: false, ...controlCallToolOptions(captainAdapter), settings: controlCallSettings(captainAgent!) }, signal);
9328
9441
  const answer = result.status === 'ok' ? controlReplyJson(result.finalText) : undefined;
9329
9442
  const index = answer && typeof answer === 'object' && !Array.isArray(answer) && Object.keys(answer).join(',') === 'instructionIndex'
9330
9443
  ? (answer as { instructionIndex: unknown }).instructionIndex : undefined;
@@ -9383,8 +9496,9 @@ export function createPlaybookCaptainShell(
9383
9496
  claim = await capability.acquire({ signal: preparationSignal }) as NonNullable<typeof claim>;
9384
9497
  await claim.assertOwner();
9385
9498
  const result = await runEffect(() => callCaptainQueued(
9386
- leaf, context, preparationPrompt,
9387
- { visibility: 'hidden', resume: false, settings: callSettings(captainAgent!) }, preparationSignal,
9499
+ leaf, context, preparationPrompt,
9500
+ { visibility: 'hidden', resume: false, settings: callSettings(captainAgent!) }, preparationSignal,
9501
+ { attachments: leaf.attachments, preparation: true },
9388
9502
  ));
9389
9503
  preparationSignal.throwIfAborted();
9390
9504
  await claim.assertOwner();
@@ -9429,6 +9543,11 @@ export function createPlaybookCaptainShell(
9429
9543
  }
9430
9544
 
9431
9545
  if (selection.action === 'deliver') {
9546
+ const available = new Map(mergeAttachments(pendingAttachments, turn.attachments, leaf.attachments ?? []).map((reference) => [reference.assetId, reference]));
9547
+ if (selection.attachmentIds?.some((id) => !available.has(id as SessionAssetRef['assetId'])) || (selection.attachmentIds && new Set(selection.attachmentIds).size !== selection.attachmentIds.length)) return rejectSelection(selection, 'the request selects unavailable attachment evidence');
9548
+ const selected = selection.attachmentIds === undefined ? turn.attachments : selection.attachmentIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9549
+ const nextAttachments = mergeAttachments(leaf.attachments ?? [], selected);
9550
+ if (nextAttachments.length) leaf.attachments = nextAttachments;
9432
9551
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
9433
9552
  // for that text — any text carried on the selection is ignored.
9434
9553
  turn.settled = true;
@@ -10077,6 +10196,7 @@ export function createPlaybookCaptainShell(
10077
10196
  : {}),
10078
10197
  ...(frame.request === undefined ? {} : { request: frame.request }),
10079
10198
  ...(frame.inputs === undefined ? {} : { inputs: [...frame.inputs] }),
10199
+ ...(frame.attachments === undefined ? {} : { attachments: [...frame.attachments] }),
10080
10200
  options: frame.enablement.options,
10081
10201
  roleBindings: Object.fromEntries(
10082
10202
  [...frame.playerBindings].map(([role, binding]) => [
@@ -10345,6 +10465,7 @@ export function createPlaybookCaptainShell(
10345
10465
  issuedSessionIds: [...issuedSessionIds],
10346
10466
  sequences: { turn: turnSequence, journal: journalSeq },
10347
10467
  journal,
10468
+ ...(pendingAttachments.length ? { pendingAttachments } : {}),
10348
10469
  ...(lastAction === undefined ? {} : { lastAction }),
10349
10470
  ...(lastSettlementStatus === undefined
10350
10471
  ? {}
@@ -10683,6 +10804,7 @@ export function createPlaybookCaptainShell(
10683
10804
  }
10684
10805
  for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
10685
10806
  journal.push(...snapshot.journal);
10807
+ pendingAttachments = [...(snapshot.pendingAttachments ?? [])];
10686
10808
  journalSeq = snapshot.sequences.journal;
10687
10809
  turnSequence = snapshot.sequences.turn;
10688
10810
  conversation = snapshot.captain.conversation;
@@ -10841,10 +10963,29 @@ export function createPlaybookCaptainShell(
10841
10963
  submitShellAction: selectShellAction,
10842
10964
  selectInterruptedReport(input, report) {
10843
10965
  if (activeTurn) throw new Error('A Captain turn is already active');
10844
- if (!input.trim() || !report.text.trim()) throw new Error('Interruption report requires its recorded input and explanation');
10966
+ if ((!input.trim() && nextTurnAttachments.length === 0) || !report.text.trim()) throw new Error('Interruption report requires its recorded input and explanation');
10845
10967
  pendingHostSelection = { kind: 'report', text: input, report };
10846
10968
  },
10847
10969
 
10970
+ recordMediaEvidence(reference, runtimeSessionId) {
10971
+ const asset = validateAssetRef(reference);
10972
+ if (!activeTurn) throw new Error('Media evidence arrived outside an active turn');
10973
+ activeTurn.evidence = mergeAttachments(activeTurn.evidence ?? [], [asset]);
10974
+ const producingIndex = frames.findIndex((candidate) => candidate.sessionId === runtimeSessionId);
10975
+ // Observed output belongs to this engagement's reasoning context: a
10976
+ // resumed parent may inspect its child's figure. Boss input additions
10977
+ // still target only the addressed leaf, and a new root inherits none.
10978
+ for (const frame of frames.slice(0, producingIndex + 1)) {
10979
+ frame.attachments = mergeAttachments(frame.attachments ?? [], [asset]);
10980
+ }
10981
+ },
10982
+
10983
+ setTurnAttachments(references) {
10984
+ if (activeTurn || activeTurnHostCalls) throw new Error('A Captain turn is already active');
10985
+ nextTurnAttachments = validateAssetRefs(references);
10986
+ onTurnAttachments?.(nextTurnAttachments);
10987
+ },
10988
+
10848
10989
  async handleBossTurn(
10849
10990
  turn: BossTurn,
10850
10991
  context: CaptainContext,
@@ -10870,7 +11011,9 @@ export function createPlaybookCaptainShell(
10870
11011
  // telemetry (CAPTAIN-7).
10871
11012
  retentionSettlementReady = false;
10872
11013
  abandonmentSettlementUnsafe = false;
10873
- if (turn.prompt.trim().length === 0) return;
11014
+ const attachments = nextTurnAttachments;
11015
+ nextTurnAttachments = [];
11016
+ if (turn.prompt.trim().length === 0 && attachments.length === 0) return;
10874
11017
  const progressBase = exportShellSnapshot();
10875
11018
  settledTurnUnresolvedEffects = undefined;
10876
11019
  retainedGenerationInstallationClosed = true;
@@ -10900,6 +11043,10 @@ export function createPlaybookCaptainShell(
10900
11043
  const parsed =
10901
11044
  decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
10902
11045
  activeTurn = {
11046
+ attachments,
11047
+ workerReports: new Map(),
11048
+ workerStepStarts: new Map(),
11049
+ workerCalls: new Map(),
10903
11050
  appliedActionCount: 0,
10904
11051
  stoppedFrames: new Set<string>(),
10905
11052
  ...(progressBase === undefined ? {} : { progressBase }),
@@ -10932,7 +11079,8 @@ export function createPlaybookCaptainShell(
10932
11079
  }
10933
11080
  }
10934
11081
  decisionCall = undefined;
10935
- appendJournal('boss', turn.prompt);
11082
+ appendJournal('boss', attachments.length ? { text: turn.prompt, attachments } as unknown as JsonValue : turn.prompt);
11083
+ pendingAttachments = mergeAttachments(pendingAttachments, attachments);
10936
11084
  try {
10937
11085
  await drainRetiredRetainedRuntimes();
10938
11086
  await prepareRetainedGenerationOffers();
@@ -10941,6 +11089,11 @@ export function createPlaybookCaptainShell(
10941
11089
  await settleSelection({ action: 'respond', text: activeTurn.interruptedReport.text }, context.signal);
10942
11090
  return;
10943
11091
  }
11092
+ if (!turn.prompt.trim() && attachments.length) {
11093
+ markConversationCatchUp();
11094
+ await settleSelection({ action: 'respond', text: 'What would you like me to do with the attached material?' }, context.signal);
11095
+ return;
11096
+ }
10944
11097
  const result = await captainRuntime.handleBossInput({
10945
11098
  text: turn.prompt,
10946
11099
  signal: context.signal,
@@ -8,15 +8,15 @@
8
8
 
9
9
  # The Captain and each stable session player carries its own defaults inline:
10
10
  # an adapter shorthand (claude, codex) or a block with
11
- # adapter/model/effort/fastMode/subagentModel/subagentEffort/permissions. A
11
+ # adapter/model/effort/fastMode/subagentModel/subagentEffort/browser/permissions. A
12
12
  # role may override only model, effort, fastMode, subagentModel, or
13
13
  # subagentEffort.
14
14
  # Omit an override to inherit the player default. For model, effort,
15
15
  # subagentModel, and subagentEffort, false selects the provider default;
16
16
  # fastMode false is a literal disabled request.
17
17
  # A Claude agent delegates by default: its subagents run on its own model
18
- # (subagentModel: inherit) at efforts it chooses per task; subagentModel: false
19
- # on its block switches delegation off.
18
+ # (subagentModel: inherit), its delegates at efforts it chooses per task;
19
+ # subagentModel: false on its block switches delegation off.
20
20
 
21
21
  # Each adapter needs its vendor SDK installed as its own top-level
22
22
  # install root — they are optional peer dependencies, so you pay only
@@ -77,6 +77,14 @@ players:
77
77
  permissions:
78
78
  mode: auto
79
79
 
80
+ # Inspection has its own conversation. Browser tools stay off until enabled.
81
+ inspect.inspector:
82
+ adapter: claude
83
+ model: claude-opus-5-5
84
+ effort: high
85
+ permissions:
86
+ mode: auto
87
+
80
88
  # Enabled playbooks. Each is loaded from its explicit `from` module; the
81
89
  # `<id>` key must equal that module's manifest id. `from`, `command`, and
82
90
  # `roles` are launcher-owned; every other key is that playbook's option
@@ -121,3 +129,8 @@ playbooks:
121
129
  from: "@sublang/playbook/pr/registry"
122
130
  roles:
123
131
  coder: dev.coder
132
+
133
+ inspect:
134
+ from: "@sublang/playbook/inspect/registry"
135
+ roles:
136
+ inspector: inspect.inspector
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import type { AgentEvent, Attachment } from '@sublang/cligent';
5
+ import type { TmuxPlayRecord } from '@sublang/cligent/tmux-play';
6
+
7
+ import type { AssetId, AssetImport, AssetFile, AssetReader, SessionAssetRef, SessionTurnInput } from './asset-types.js';
8
+ export type { AssetId, AssetImport, AssetFile, AssetReader, SessionAssetRef, SessionTurnInput } from './asset-types.js';
9
+
10
+ export interface OwnerAssetStore {
11
+ readonly directory: string;
12
+ /** Verify ownership and tighten Git checkout permissions without changing content. */
13
+ prepare(): Promise<void>;
14
+ importAsset(input: AssetImport): Promise<SessionAssetRef>;
15
+ describeAsset(assetId: AssetId): Promise<SessionAssetRef>;
16
+ openAsset(reference: SessionAssetRef | AssetId, options?: { readonly signal?: AbortSignal }): Promise<AssetReader>;
17
+ readAsset(reference: SessionAssetRef | AssetId, options?: { readonly signal?: AbortSignal }): Promise<Uint8Array>;
18
+ resolveAttachment(reference: SessionAssetRef, options?: { readonly signal?: AbortSignal }): Promise<Attachment>;
19
+ copyAsset(source: Pick<OwnerAssetStore, 'readAsset'>, reference: SessionAssetRef, options?: { readonly signal?: AbortSignal }): Promise<SessionAssetRef>;
20
+ listAssets(): Promise<readonly AssetFile[]>;
21
+ }
22
+ export declare const DEFAULT_MAX_ASSET_BYTES: number;
23
+ export declare function createAssetStore(options: { readonly directory: string; readonly maxAssetBytes?: number }): OwnerAssetStore;
24
+ export declare function validateAssetRef(value: unknown): SessionAssetRef;
25
+ export declare function validateAssetRefs(value: unknown): readonly SessionAssetRef[];
26
+ export declare function normalizeSessionTurnInput(value: string | SessionTurnInput): { readonly text: string; readonly attachments: readonly SessionAssetRef[] };
27
+ export declare function assetUri(reference: SessionAssetRef): string;
28
+ export declare function parseAssetUri(value: string): AssetId | undefined;
29
+ /** Throws if native content cannot be persisted; the caller must report it as unavailable. */
30
+ export declare function externalizeAgentEvent(store: Pick<OwnerAssetStore, 'importAsset'>, event: AgentEvent): Promise<{ readonly event: AgentEvent; readonly asset?: SessionAssetRef }>;
31
+
32
+ export type SessionTurnStartedRecord = Omit<Extract<TmuxPlayRecord, { type: 'turn_started' }>, 'turn'> & {
33
+ readonly turn: Extract<TmuxPlayRecord, { type: 'turn_started' }>['turn'] & {
34
+ readonly attachments?: readonly SessionAssetRef[];
35
+ };
36
+ };
37
+ export interface PlaybookEvidenceRecord {
38
+ readonly type: 'playbook_evidence';
39
+ readonly timestamp: number;
40
+ readonly turnId: number;
41
+ /** Playbook trace call identity, or a host-owned invocation-scope identity. */
42
+ readonly callId: string;
43
+ readonly origin: {
44
+ readonly kind: 'player' | 'preparation';
45
+ readonly actorId: string;
46
+ /** Producing Playbook runtime identity, not the native provider session. */
47
+ readonly runtimeSessionId?: string;
48
+ readonly toolUseId?: string;
49
+ };
50
+ readonly asset: SessionAssetRef;
51
+ }
52
+ /** Known host presentation records. Unknown replay records remain opaque JSON. */
53
+ export type SessionRecord = Exclude<TmuxPlayRecord, { type: 'turn_started' }> | SessionTurnStartedRecord | PlaybookEvidenceRecord;