@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
@@ -2,6 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { normalizeSessionTurnInput } from '../session-assets.js';
5
6
  import { createCaptainSessionStore, isUncertainTurnDiscardable, projectCaptainSessionStructure } from './session-store.js';
6
7
  import { createCaptainSessionHost, executionConfigFromPlan, installRetainedGenerationsForLaunch, validateFrozenExecutionConfig } from './run.js';
7
8
  import { createReplayRecordObserver } from './replay-observer.js';
@@ -33,9 +34,10 @@ export async function openSessionHost(options) {
33
34
  if (record !== undefined) await lease.assertContinuable({ cwd, executionProjection: selected });
34
35
  const config = await validateFrozenExecutionConfig(structure, selected, { loadModule, prepareRegistryModule: options.prepareRegistryModule });
35
36
  const replay = createReplayRecordObserver({ lease, onIncomplete: options.onIncomplete ?? (() => {}), onStored: options.onStoredRecord });
36
- let terminal, replies = [];
37
+ let terminal, replies = [], submittedAttachments = [];
37
38
  const observe = {
38
39
  async onRecord(value) {
40
+ if (value.type === 'turn_started' && submittedAttachments.length) value = { ...value, turn: { ...value.turn, attachments: submittedAttachments } };
39
41
  if (value.type === "turn_finished" || value.type === "turn_aborted") terminal = value;
40
42
  if (value.type === "captain_reply") replies.push(value);
41
43
  await replay.observer.onRecord(value);
@@ -53,14 +55,21 @@ export async function openSessionHost(options) {
53
55
  const execute = async (input, retry, actionId, shellActionId) => {
54
56
  assertIdle();
55
57
  let attemptId;
58
+ let attachments = [];
56
59
  const operation = (async () => {
57
60
  let prior = await lease.read();
58
61
  if (retry) {
59
62
  if (prior?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
60
63
  input = prior.uncertain.input;
64
+ attachments = prior.uncertain.attachments ?? [];
61
65
  if (!retryPending) throw new Error(RECOVERY_REOPEN);
62
66
  } else if (prior?.state !== 'settled') throw new Error(RECOVERY_REOPEN);
63
- if (actionId === undefined && shellActionId === undefined && (typeof input !== 'string' || input.trim().length === 0)) throw new Error('session input must be nonempty');
67
+ if (actionId === undefined && shellActionId === undefined) {
68
+ const normalized = normalizeSessionTurnInput(retry ? { text: input, attachments } : input);
69
+ input = normalized.text; attachments = normalized.attachments;
70
+ if (!input.trim() && attachments.length === 0) throw new Error('session input must contain text or attachments');
71
+ if (attachments.length) await lease.resolveAttachments(attachments, { signal: options.signal });
72
+ }
64
73
  await created.reconcileRepositoryEffects();
65
74
  // The selection is made after reconciliation, so the advertised
66
75
  // reading it is validated against is the one this turn settles
@@ -74,11 +83,13 @@ export async function openSessionHost(options) {
74
83
  }
75
84
  terminal = undefined; replies = [];
76
85
  attemptId = options.createAttemptId?.() ?? randomUUID();
77
- const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attemptId, attemptedExecutionProjection: config });
86
+ const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attachments, attemptId, attemptedExecutionProjection: config });
78
87
  retryPending = false;
79
88
  await replay.flushStoredRecords();
80
89
  await options.onCheckpoint?.(marked);
81
90
  await lease.assertOwner();
91
+ submittedAttachments = attachments;
92
+ created.shell.setTurnAttachments?.(attachments);
82
93
  await created.host.runBossTurn(input);
83
94
  if (terminal?.type !== "turn_finished" || replies.length !== 1 || typeof replies[0].text !== "string" || replies[0].text.trim().length === 0) throw new Error("Captain turn did not finish with one reply; session remains uncertain");
84
95
  const settlement = created.shell.exportSettlement();
@@ -101,12 +112,13 @@ export async function openSessionHost(options) {
101
112
  throw error;
102
113
  });
103
114
  active = operation;
104
- try { return await operation; } finally { active = undefined; }
115
+ try { return await operation; } finally { active = undefined; submittedAttachments = []; }
105
116
  };
106
117
 
107
118
  const dispose = () => {
108
119
  if (closed) return Promise.resolve();
109
120
  if (closing) return closing;
121
+ created.cancelPendingApprovals();
110
122
  closing = (async () => {
111
123
  try { await active; } catch { /* Preserve the durable uncertain marker. */ }
112
124
  await created.host.dispose();
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import { externalizeAgentEvent } from '../session-assets.js';
6
+
7
+ /** Externalize authentic observed media before the opaque replay writer sees it. */
8
+ export function createSessionMediaProjector({ lease, onEvidence }) {
9
+ const players = new Map();
10
+ let preparation;
11
+ const seen = new Map();
12
+ const beginPreparation = (runtimeSessionId) => {
13
+ if (preparation) throw new Error('a preparation media scope is already active');
14
+ const scope = { callId: randomUUID(), origin: { kind: 'preparation', actorId: 'captain', runtimeSessionId } };
15
+ preparation = scope;
16
+ return () => { if (preparation === scope) preparation = undefined; seen.delete(scope.callId); };
17
+ };
18
+ const project = async (record) => {
19
+ if (record.type === 'captain_telemetry' && record.topic === 'playbook.trace') {
20
+ const trace = record.payload;
21
+ if (trace?.schemaVersion === 4 && trace.type === 'player.call.started' && typeof trace.callId === 'string' && typeof trace.sessionId === 'string' && typeof trace.payload?.playerId === 'string') {
22
+ players.set(trace.payload.playerId, { callId: trace.callId, origin: { kind: 'player', actorId: trace.payload.playerId, runtimeSessionId: trace.sessionId } });
23
+ }
24
+ }
25
+ if (record.type === 'player_prompt' && !players.has(record.playerId)) players.set(record.playerId, { callId: randomUUID(), origin: { kind: 'player', actorId: record.playerId } });
26
+ if (record.type === 'player_finished') { const scope = players.get(record.playerId); if (scope) seen.delete(scope.callId); players.delete(record.playerId); }
27
+ if (record.type !== 'player_event' && record.type !== 'captain_event') return [record];
28
+ const event = record.event;
29
+ const scope = record.type === 'player_event' ? players.get(record.playerId) : preparation;
30
+ try {
31
+ const { event: projected, asset } = await externalizeAgentEvent(lease, event);
32
+ const stored = projected === event ? record : { ...record, event: projected };
33
+ if (!asset || !scope || typeof record.turnId !== 'number') return [stored];
34
+ let emitted = seen.get(scope.callId);
35
+ if (!emitted) { emitted = new Set(); seen.set(scope.callId, emitted); }
36
+ if (emitted.has(asset.assetId)) return [stored];
37
+ emitted.add(asset.assetId);
38
+ const origin = { ...scope.origin, ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}) };
39
+ await onEvidence?.(asset, origin);
40
+ return [stored, { type: 'playbook_evidence', timestamp: record.timestamp, turnId: record.turnId, callId: scope.callId, origin, asset }];
41
+ } catch (error) {
42
+ return [{ type: 'runtime_error', timestamp: record.timestamp, turnId: record.turnId, sourceRecordType: record.type, message: `Media could not be saved: ${error instanceof Error ? error.message : String(error)}` }];
43
+ }
44
+ };
45
+ return Object.freeze({ project, beginPreparation });
46
+ }
@@ -7,6 +7,7 @@
7
7
  // complete retained-generation map under guarded source-first publication.
8
8
 
9
9
  import { createHash, randomUUID } from 'node:crypto';
10
+ import { createAssetStore, parseAssetUri, validateAssetRef, validateAssetRefs } from '../session-assets.js';
10
11
  import {
11
12
  SESSION_MANIFEST_VERSION, EMPTY_REPLAY_SHA256, sha256,
12
13
  validateSessionManifest, recoveryFromManifest, manifestFromRecovery,
@@ -637,6 +638,12 @@ export function createCaptainSessionStore(options = {}) {
637
638
  const replayReadCursors = new Map();
638
639
  const replayReadQueues = new Map();
639
640
  const portableWriters = new Map();
641
+ const assetStores = new Map();
642
+ const assetStoreFor = (sessionId) => {
643
+ assertSessionId(sessionId);
644
+ if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
645
+ return assetStores.get(sessionId);
646
+ };
640
647
 
641
648
  if (!isAbsolute(sessionsDir)) {
642
649
  throw new Error('Captain session store path must be absolute');
@@ -758,7 +765,11 @@ export function createCaptainSessionStore(options = {}) {
758
765
  if (value.sessionId !== sessionId) throw new Error('session manifest identity does not match its filename');
759
766
  return value.schemaVersion === 7 ? validateSessionManifest(value) : value;
760
767
  };
761
- const prepare = () => prepareSessionPermissions(sessionsDir, fs);
768
+ const prepare = async () => {
769
+ await prepareSessionPermissions(sessionsDir, fs);
770
+ let names; try { names = await fs.readdir(sessionsDir); } catch (cause) { if (cause?.code === 'ENOENT') return; throw cause; }
771
+ for (const name of names) if (name.endsWith('.assets') && SESSION_ID_PATTERN.test(name.slice(0, -7))) await assetStoreFor(name.slice(0, -7)).prepare();
772
+ };
762
773
  const readHistory = async (sessionId, options = {}) => {
763
774
  assertSessionId(sessionId);
764
775
  const history = await readSessionHistory({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: options.afterSeq ?? 0 });
@@ -771,6 +782,35 @@ export function createCaptainSessionStore(options = {}) {
771
782
  } catch { return history; }
772
783
  return legacyJournalHistory(legacy, options.afterSeq ?? 0, history);
773
784
  };
785
+ // Only our typed reference locations participate; opaque tool/text data is history.
786
+ const assertAssetInventory = (manifest, entries) => {
787
+ const declared = new Map((manifest.assets?.entries ?? []).map((reference) => [reference.assetId, reference]));
788
+ const check = (reference) => {
789
+ const valid = validateAssetRef(reference);
790
+ if (declared.get(valid.assetId)?.byteLength !== valid.byteLength) throw new Error(`session asset reference is absent from its checkpoint inventory: ${valid.assetId}`);
791
+ };
792
+ const checkRefs = (references) => { for (const reference of references ?? []) check(reference); };
793
+ const snapshot = (value) => {
794
+ if (!value) return;
795
+ checkRefs(value.pendingAttachments);
796
+ for (const frame of value.frames ?? []) checkRefs(frame.attachments);
797
+ for (const item of value.journal ?? []) if (item.kind === 'boss' && typeof item.payload === 'object' && item.payload !== null) checkRefs(item.payload.attachments);
798
+ };
799
+ snapshot(manifest.snapshot); snapshot(manifest.uncertain?.progress?.snapshot);
800
+ checkRefs(manifest.uncertain?.attachments);
801
+ for (const generation of Object.values(manifest.retainedGenerations ?? {})) for (const frame of generation.frames ?? []) checkRefs(frame.attachments);
802
+ for (const { record } of entries) {
803
+ if (record.type === 'turn_started') checkRefs(record.turn?.attachments);
804
+ else if (record.type === 'playbook_evidence') check(record.asset);
805
+ else if (record.type === 'player_event' || record.type === 'captain_event') {
806
+ const event = record.event;
807
+ if (event?.type === 'media' && event.payload?.source?.type === 'uri') {
808
+ const id = parseAssetUri(event.payload.source.uri);
809
+ if (id && !declared.has(id)) throw new Error(`session media asset is absent from its checkpoint inventory: ${id}`);
810
+ } else if (event?.type === 'tool_result' && event.payload?.output?.type === 'asset_reference') check(event.payload.output.asset);
811
+ }
812
+ }
813
+ };
774
814
  const validate = async (sessionId, context = {}) => {
775
815
  const manifest = await readManifest(sessionId);
776
816
  const history = await readHistory(sessionId);
@@ -790,6 +830,12 @@ export function createCaptainSessionStore(options = {}) {
790
830
  integrityValid = false;
791
831
  reasons.push('session replay contains provider continuation fields');
792
832
  }
833
+ try { assertAssetInventory(manifest, history.entries.filter(({ seq }) => seq <= manifest.replay.seq)); }
834
+ catch (cause) { integrityValid = false; reasons.push(errorMessage(cause)); }
835
+ for (const reference of manifest.assets?.entries ?? []) {
836
+ try { const reader = await assetStoreFor(sessionId).openAsset(reference); await reader.close(); }
837
+ catch (cause) { integrityValid = false; reasons.push(`session asset is unavailable: ${errorMessage(cause)}`); }
838
+ }
793
839
  if (manifest.state === 'history-only') reasons.push(manifest.reason);
794
840
  if (manifest.replay.incomplete || history.incomplete) reasons.push('session replay is incomplete');
795
841
  if (history.digests[manifest.replay.seq] !== manifest.replay.sha256) { integrityValid = false; reasons.push('session replay checkpoint digest does not match'); }
@@ -1111,6 +1157,18 @@ export function createCaptainSessionStore(options = {}) {
1111
1157
  };
1112
1158
 
1113
1159
  const deleteRecord = async (sessionId) => {
1160
+ const assetsDirectory = join(sessionsDir, `${sessionId}.assets`);
1161
+ try {
1162
+ await assertPrivateDirectory(assetsDirectory, fs);
1163
+ const names = await fs.readdir(assetsDirectory);
1164
+ for (const name of names) {
1165
+ if (!/^(?:[0-9a-f]{64}(?:\.json)?|\.import-[0-9a-f-]+\.tmp)$/.test(name)) throw new Error('unknown session asset entry prevents deletion');
1166
+ await assertPrivateRegularPath(join(assetsDirectory, name), 0o600, fs, 'session asset');
1167
+ }
1168
+ for (const name of names) await fs.unlink(join(assetsDirectory, name));
1169
+ await fs.rmdir(assetsDirectory);
1170
+ await syncDirectory(sessionsDir, fs);
1171
+ } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1114
1172
  for (const suffix of ['.records.jsonl', '.hints.json', '.spex.json', '.json']) {
1115
1173
  const path = join(sessionsDir, `${sessionId}${suffix}`);
1116
1174
  try { await assertPrivateRegularPath(path, 0o600, fs, 'session file'); await fs.unlink(path); await syncDirectory(sessionsDir, fs); }
@@ -1396,6 +1454,7 @@ export function createCaptainSessionStore(options = {}) {
1396
1454
  const acquire = async (sessionId, management = false) => {
1397
1455
  assertSessionId(sessionId);
1398
1456
  await prepareSessionPermissions(sessionsDir, fs, sessionId);
1457
+ await assetStoreFor(sessionId).prepare();
1399
1458
  let stage;
1400
1459
  let stagePublished = false;
1401
1460
  try {
@@ -1451,6 +1510,7 @@ export function createCaptainSessionStore(options = {}) {
1451
1510
  readHistory,
1452
1511
  validateSession: validate,
1453
1512
  portableWriters,
1513
+ assetStore: assetStoreFor(sessionId),
1454
1514
  writeRecord,
1455
1515
  syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
1456
1516
  deleteRecord,
@@ -1684,8 +1744,39 @@ export function createCaptainSessionStore(options = {}) {
1684
1744
  finally { await lease.release(); }
1685
1745
  };
1686
1746
 
1747
+ const exportBundle = async (sessionId) => {
1748
+ assertSessionId(sessionId);
1749
+ for (let attempt = 0; attempt < 3; attempt++) {
1750
+ const manifestText = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record');
1751
+ const manifest = validateSessionManifest(JSON.parse(manifestText));
1752
+ const replayBytes = Buffer.from(await readPrivateRegularFile(recordsPathFor(sessionId), 0o600, fs, 'replay stream'), 'utf8');
1753
+ let end = 0;
1754
+ for (let seq = 0; seq < manifest.replay.seq; seq++) {
1755
+ const next = replayBytes.indexOf(10, end);
1756
+ if (next === -1) throw new Error('session bundle is missing its checkpoint replay prefix');
1757
+ end = next + 1;
1758
+ }
1759
+ const replay = replayBytes.subarray(0, end);
1760
+ if (sha256(replay) !== manifest.replay.sha256 || manifest.replay.incomplete) throw new Error('session bundle replay checkpoint is incomplete or corrupt');
1761
+ assertAssetInventory(manifest, parseSessionHistory(replay).entries);
1762
+ const assets = [];
1763
+ for (const reference of manifest.assets?.entries ?? []) {
1764
+ const reader = await assetStoreFor(sessionId).openAsset(reference); await reader.close();
1765
+ const hash = reference.assetId.slice(7);
1766
+ assets.push(Object.freeze({ assetId: reference.assetId, path: hash, metadataPath: `${hash}.json`, byteLength: reference.byteLength }));
1767
+ }
1768
+ if (manifestText !== await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record')) continue;
1769
+ return Object.freeze({ sessionId, manifest: Buffer.from(manifestText, 'utf8'), replay, assets: Object.freeze(assets) });
1770
+ }
1771
+ throw new Error('session changed while exporting its bundle; retry');
1772
+ };
1773
+
1687
1774
  return Object.freeze({
1688
1775
  sessionsDir,
1776
+ readAsset: (sessionId, reference, options) => assetStoreFor(sessionId).readAsset(reference, options),
1777
+ openAsset: (sessionId, reference, options) => assetStoreFor(sessionId).openAsset(reference, options),
1778
+ describeAsset: (sessionId, assetId) => assetStoreFor(sessionId).describeAsset(assetId),
1779
+ exportBundle,
1689
1780
  prepare,
1690
1781
  migrate,
1691
1782
  migrateLegacyDefault,
@@ -2540,6 +2631,7 @@ async function createLease({
2540
2631
  readHistory,
2541
2632
  validateSession,
2542
2633
  portableWriters,
2634
+ assetStore,
2543
2635
  writeRecord,
2544
2636
  syncRecordDirectory,
2545
2637
  deleteRecord,
@@ -2636,7 +2728,9 @@ async function createLease({
2636
2728
  const seq = history.digests[durableSeq] === undefined ? previousManifest?.replay?.seq ?? 0 : durableSeq;
2637
2729
  const digest = history.digests[seq] ?? previousManifest?.replay?.sha256 ?? EMPTY_REPLAY_SHA256;
2638
2730
  const replay = { seq, sha256: digest, incomplete: previousManifest?.replay?.incomplete === true || status.incomplete || history.incomplete || history.digests[seq] === undefined };
2639
- const manifest = manifestFromRecovery(recovery, replay, contextSeq);
2731
+ const entries = await assetStore.listAssets();
2732
+ const assets = entries.length === 0 ? undefined : { version: 1, entries: await Promise.all(entries.map(({ assetId }) => assetStore.describeAsset(assetId))) };
2733
+ const manifest = manifestFromRecovery(recovery, replay, contextSeq, assets);
2640
2734
  return manifest;
2641
2735
  };
2642
2736
  portableWriters.set(sessionId, { checkpoint, sync: replayWriter.checkpoint, published: (manifest) => { previousManifest = manifest; } });
@@ -2928,6 +3022,8 @@ async function createLease({
2928
3022
  source.effectLedger,
2929
3023
  );
2930
3024
 
3025
+ const inheritedAssets = new Map(Object.values(retainedGenerations).flatMap((generation) => generation.frames.flatMap((frame) => frame.attachments ?? [])).map((reference) => [reference.assetId, reference]));
3026
+ for (const reference of inheritedAssets.values()) await assetStore.copyAsset(sourceLease, reference);
2931
3027
  await sourceLease.assertOwner();
2932
3028
  await assertOwnerUnchecked();
2933
3029
  let sourcePublished = false;
@@ -3026,11 +3122,12 @@ async function createLease({
3026
3122
 
3027
3123
  const beginTurn = ({
3028
3124
  input,
3125
+ attachments,
3029
3126
  attemptId,
3030
3127
  attemptedExecutionProjection,
3031
3128
  } = {}) =>
3032
3129
  runExclusive(async () => {
3033
- assertAcceptedInput(input);
3130
+ assertAcceptedInput(input, attachments);
3034
3131
  assertUuid(attemptId, 'Captain session attempt id');
3035
3132
  const attempted = validateCaptainSessionExecutionProjection(
3036
3133
  attemptedExecutionProjection,
@@ -3065,6 +3162,7 @@ async function createLease({
3065
3162
  uncertain: {
3066
3163
  baseUpdatedAt: prior.updatedAt,
3067
3164
  input,
3165
+ ...(attachments?.length ? { attachments: validateAssetRefs(attachments) } : {}),
3068
3166
  attemptId,
3069
3167
  attemptNumber: 1,
3070
3168
  markedAt: timestamp,
@@ -3119,6 +3217,7 @@ async function createLease({
3119
3217
  baseUpdatedAt: prior.uncertain.baseUpdatedAt,
3120
3218
  ...(prior.uncertain.progress === undefined ? {} : { progress: prior.uncertain.progress }),
3121
3219
  input: prior.uncertain.input,
3220
+ ...(prior.uncertain.attachments === undefined ? {} : { attachments: prior.uncertain.attachments }),
3122
3221
  attemptId: nextAttemptId,
3123
3222
  attemptNumber: prior.uncertain.attemptNumber + 1,
3124
3223
  markedAt: timestamp,
@@ -3637,11 +3736,11 @@ async function createLease({
3637
3736
  if (Object.hasOwn(step, 'result') && !['completion', 'answer'].includes(step.kind)) throw new Error('A step result requires its saved start');
3638
3737
  progress.steps.push(step);
3639
3738
  } else {
3640
- const { result, ...start } = step;
3739
+ const { result, workerEvidence, ...start } = step;
3641
3740
  const saved = progress.steps[index];
3642
- const { result: priorResult, ...priorStart } = saved;
3741
+ const { result: priorResult, workerEvidence: priorEvidence, ...priorStart } = saved;
3643
3742
  if (!isDeepStrictEqual(start, priorStart) ||
3644
- (priorResult !== undefined && !isDeepStrictEqual(priorResult, result))) {
3743
+ (priorResult !== undefined && (!isDeepStrictEqual(priorResult, result) || !isDeepStrictEqual(priorEvidence, workerEvidence)))) {
3645
3744
  throw new Error('A saved step cannot be replaced');
3646
3745
  }
3647
3746
  progress.steps[index] = step;
@@ -3728,6 +3827,11 @@ async function createLease({
3728
3827
  sessionId,
3729
3828
  ownerToken: owner.ownerToken,
3730
3829
  append,
3830
+ importAsset: (input) => runExclusive(async () => { await assertOwnerUnchecked(); return assetStore.importAsset(input); }),
3831
+ resolveAttachments: (references, options) => runExclusive(async () => { await assertOwnerUnchecked(); if (!Array.isArray(references)) throw new TypeError('attachments must be an array'); return Promise.all(references.map((reference) => assetStore.resolveAttachment(reference, options))); }),
3832
+ readAsset: (reference, options) => assetStore.readAsset(reference, options),
3833
+ openAsset: (reference, options) => assetStore.openAsset(reference, options),
3834
+ describeAsset: (assetId) => assetStore.describeAsset(assetId),
3731
3835
  recordContext,
3732
3836
  consumeHints,
3733
3837
  acknowledgeHint,
@@ -4089,7 +4193,7 @@ function validateCanonicalCaptainSessionRecord(
4089
4193
  exactOptionalKeys(
4090
4194
  uncertain,
4091
4195
  UNCERTAIN_KEYS,
4092
- ['abandonment', 'progress'],
4196
+ ['abandonment', 'progress', 'attachments'],
4093
4197
  'Captain session record uncertain',
4094
4198
  );
4095
4199
  if (uncertain.baseUpdatedAt !== null) {
@@ -4110,7 +4214,7 @@ function validateCanonicalCaptainSessionRecord(
4110
4214
  );
4111
4215
  }
4112
4216
  }
4113
- assertAcceptedInput(uncertain.input);
4217
+ assertAcceptedInput(uncertain.input, uncertain.attachments);
4114
4218
  assertUuid(uncertain.attemptId, 'Captain session attempt id');
4115
4219
  if (
4116
4220
  !Number.isSafeInteger(uncertain.attemptNumber) ||
@@ -4172,8 +4276,9 @@ function validateCanonicalCaptainSessionRecord(
4172
4276
  exactOptionalKeys(progress, ['snapshot', 'steps'], ['positionStepId'], 'Captain progress');
4173
4277
  if (!Array.isArray(progress.steps)) throw new Error('Captain steps must be an array');
4174
4278
  const ids = new Set();
4279
+ const workerBoundaryIds = new Set();
4175
4280
  for (const step of progress.steps) {
4176
- exactOptionalKeys(requireRecord(step, 'Captain step'), ['id', 'kind', 'stateId', 'runtimeSessionId', 'playbookId'], ['result'], 'Captain step');
4281
+ exactOptionalKeys(requireRecord(step, 'Captain step'), ['id', 'kind', 'stateId', 'runtimeSessionId', 'playbookId'], ['result', 'workerEvidence'], 'Captain step');
4177
4282
  assertUuid(step.id, 'Captain step id');
4178
4283
  assertUuid(step.runtimeSessionId, 'Captain step runtime');
4179
4284
  if (ids.has(step.id)) throw new Error('Captain step ids must be unique');
@@ -4209,6 +4314,22 @@ function validateCanonicalCaptainSessionRecord(
4209
4314
  }
4210
4315
  requireCanonicalNonblank(step.stateId, 'Captain step state');
4211
4316
  if (!Object.hasOwn(structural.catalog, step.playbookId)) throw new Error('Captain step names an unknown playbook');
4317
+ if (step.workerEvidence !== undefined) {
4318
+ const evidence = requireRecord(step.workerEvidence, 'Worker evidence');
4319
+ exactOptionalKeys(evidence, ['boundaryId', 'playerId'], [], 'Worker evidence');
4320
+ assertUuid(evidence.boundaryId, 'Worker evidence boundary');
4321
+ requireCanonicalNonblank(evidence.playerId, 'Worker evidence player');
4322
+ const boundary = record.effectLedger.boundaries.find((entry) => entry.boundaryId === evidence.boundaryId);
4323
+ if (step.kind !== 'player' || !step.result || typeof step.result !== 'object' || Array.isArray(step.result) ||
4324
+ !boundary || boundary.playbookId !== step.playbookId || boundary.sourceStateId !== step.stateId ||
4325
+ workerBoundaryIds.has(evidence.boundaryId) || isDeepStrictEqual(boundary, record.snapshot.effectLedger.boundaries[boundary.sequence - 1]) ||
4326
+ !boundary.physicalReceipt || !boundary.finalText?.trim() ||
4327
+ boundary.semanticCandidate?.guard !== step.result.guard || typeof step.result.guard !== 'string' || step.result.guard === 'needsBossReply' ||
4328
+ structural.catalog[step.playbookId]?.roles?.[boundary.roleId]?.playerId !== evidence.playerId) {
4329
+ throw new Error('Worker evidence must name its accepted player boundary');
4330
+ }
4331
+ workerBoundaryIds.add(evidence.boundaryId);
4332
+ }
4212
4333
  }
4213
4334
  if (progress.positionStepId !== undefined && progress.positionStepId !== null && !ids.has(progress.positionStepId)) throw new Error('Captain progress positionStepId must name a recorded step');
4214
4335
  if (progress.snapshot === null && progress.positionStepId != null) throw new Error('Captain progress positionStepId requires a saved position');
@@ -5312,7 +5433,7 @@ function assertReleasedSchema2CaptainSessionRecord(record) {
5312
5433
  );
5313
5434
  }
5314
5435
  }
5315
- assertAcceptedInput(uncertain.input);
5436
+ assertAcceptedInput(uncertain.input, uncertain.attachments);
5316
5437
  assertUuid(uncertain.attemptId, 'Captain session attempt id');
5317
5438
  if (
5318
5439
  !Number.isSafeInteger(uncertain.attemptNumber) ||
@@ -5402,7 +5523,14 @@ function validateCaptainSessionProjection(
5402
5523
  : ['id', 'adapter', 'model', 'effort'],
5403
5524
  structural
5404
5525
  ? ['instruction', 'permissions']
5405
- : ['instruction', 'permissions', 'fastMode', 'subagentModel'],
5526
+ : [
5527
+ 'instruction',
5528
+ 'permissions',
5529
+ 'fastMode',
5530
+ 'subagentModel',
5531
+ 'subagentEffort',
5532
+ 'browser',
5533
+ ],
5406
5534
  playerPath,
5407
5535
  );
5408
5536
  assertPlayerId(player.id, `${playerPath}.id`);
@@ -5503,16 +5631,13 @@ function validateCaptainSessionProjection(
5503
5631
  structural
5504
5632
  ? ['playerId']
5505
5633
  : ['playerId', 'model', 'effort'],
5506
- structural ? [] : ['fastMode', 'subagentModel'],
5634
+ structural ? [] : ['fastMode', 'subagentModel', 'subagentEffort'],
5507
5635
  bindingPath,
5508
5636
  );
5509
5637
  assertPlayerId(binding.playerId, `${bindingPath}.playerId`);
5510
5638
  if (!structural) {
5511
5639
  validateProjectedFastMode(binding, `${bindingPath}.fastMode`);
5512
- validateProjectedSubagentModel(
5513
- binding,
5514
- `${bindingPath}.subagentModel`,
5515
- );
5640
+ validateProjectedSubagentTuning(binding, bindingPath);
5516
5641
  validateTuningSelection(binding.model, `${bindingPath}.model`);
5517
5642
  const player = projection.players.find(
5518
5643
  (candidate) => candidate.id === binding.playerId,
@@ -5560,10 +5685,14 @@ function validateProjectedFastMode(record, path) {
5560
5685
  }
5561
5686
  }
5562
5687
 
5563
- // Like fast mode, absence is the canonical provider default (DR-075).
5564
- function validateProjectedSubagentModel(record, path) {
5565
- if (Object.hasOwn(record, 'subagentModel')) {
5566
- requireNonblank(record.subagentModel, path);
5688
+ // Like fast mode, absence is the canonical provider default (DR-075); the
5689
+ // subagent model may be the literal `inherit`, and an absent subagent effort
5690
+ // leaves each subagent's effort to the agent (DR-076).
5691
+ function validateProjectedSubagentTuning(record, path) {
5692
+ for (const field of ['subagentModel', 'subagentEffort']) {
5693
+ if (Object.hasOwn(record, field)) {
5694
+ requireNonblank(record[field], `${path}.${field}`);
5695
+ }
5567
5696
  }
5568
5697
  }
5569
5698
 
@@ -5571,7 +5700,8 @@ function validateProjectedAgent(value, path, { structural, hasId = false }) {
5571
5700
  const agent = requireRecord(value, path);
5572
5701
  if (!structural) {
5573
5702
  validateProjectedFastMode(agent, `${path}.fastMode`);
5574
- validateProjectedSubagentModel(agent, `${path}.subagentModel`);
5703
+ if (agent.browser !== undefined && typeof agent.browser !== 'boolean') throw new Error(`${path}.browser must be a boolean`);
5704
+ validateProjectedSubagentTuning(agent, path);
5575
5705
  }
5576
5706
  if (!hasId) {
5577
5707
  exactOptionalKeys(
@@ -5579,7 +5709,14 @@ function validateProjectedAgent(value, path, { structural, hasId = false }) {
5579
5709
  structural ? ['adapter'] : ['adapter', 'model', 'effort'],
5580
5710
  structural
5581
5711
  ? ['instruction', 'permissions']
5582
- : ['instruction', 'permissions', 'fastMode', 'subagentModel'],
5712
+ : [
5713
+ 'instruction',
5714
+ 'permissions',
5715
+ 'fastMode',
5716
+ 'subagentModel',
5717
+ 'subagentEffort',
5718
+ 'browser',
5719
+ ],
5583
5720
  path,
5584
5721
  );
5585
5722
  }
@@ -6131,9 +6268,10 @@ function validateRetainedGenerationFrame(
6131
6268
  'roleBindings',
6132
6269
  'runtime',
6133
6270
  ],
6134
- ['parentSessionId', 'parentCallId', 'request', 'inputs'],
6271
+ ['parentSessionId', 'parentCallId', 'request', 'inputs', 'attachments'],
6135
6272
  path,
6136
6273
  );
6274
+ if (frame.attachments !== undefined) validateAssetRefs(frame.attachments);
6137
6275
  if (frame.request !== undefined) {
6138
6276
  if (index !== 0) throw new Error(`${path}.request belongs only to the root`);
6139
6277
  requireNonblank(frame.request, `${path}.request`);
@@ -6356,8 +6494,9 @@ async function requireUncertainRecord(record, attemptId) {
6356
6494
  return record;
6357
6495
  }
6358
6496
 
6359
- function assertAcceptedInput(value) {
6360
- if (typeof value !== 'string' || value.trim().length === 0) {
6497
+ function assertAcceptedInput(value, attachments) {
6498
+ if (attachments !== undefined) validateAssetRefs(attachments);
6499
+ if (typeof value !== 'string' || (value.trim().length === 0 && !attachments?.length)) {
6361
6500
  throw new Error('Captain session input must be a non-empty string');
6362
6501
  }
6363
6502
  }
@@ -3,21 +3,28 @@ import type { Effort, PermissionPolicy } from '@sublang/cligent';
3
3
  import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookFailureCause, PlaybookRuntime, PlaybookRuntimeSnapshot, PlaybookStepRecord } from '@sublang/playbook/runtime';
4
4
  import { type CaptainControllerPort } from '../captain.playbook/captain.playbook.js';
5
5
  import type { PlaybookSummaryPolicy } from './code.registry.js';
6
+ import type { Attachment } from '@sublang/cligent';
7
+ import type { SessionAssetRef } from './session-assets.js';
6
8
  interface SessionAgent {
9
+ /** Mutable next-turn execution capability; never structural identity. */
10
+ readonly browser?: boolean;
7
11
  readonly adapter: string;
8
12
  readonly model: TuningSelection;
9
13
  readonly effort: TuningSelection<Effort>;
10
14
  /** Adapter-scoped fast mode. Absence is the provider default; `false` is a
11
15
  * literal request, so this carries no provider-default sentinel. */
12
16
  readonly fastMode?: boolean;
13
- /** The model every subagent of this agent runs on (DR-075). Absence is the
14
- * provider default. */
17
+ /** The model every subagent of this agent runs on (DR-075), or `inherit`
18
+ * for the agent's own model (DR-076). Absence is the provider default. */
15
19
  readonly subagentModel?: string;
20
+ /** The effort every subagent of this agent runs at (DR-076). Absence leaves
21
+ * each subagent's effort to the agent. */
22
+ readonly subagentEffort?: string;
16
23
  readonly instruction?: string;
17
24
  readonly permissions?: PermissionPolicy;
18
25
  }
19
26
  /** Per-call tuning: erased from every structural envelope and snapshot. */
20
- type TuningKey = 'model' | 'effort' | 'fastMode' | 'subagentModel';
27
+ type TuningKey = 'model' | 'effort' | 'fastMode' | 'subagentModel' | 'subagentEffort';
21
28
  interface PlayerLedgerEntry {
22
29
  readonly adapter: string;
23
30
  readonly instruction?: string;
@@ -37,7 +44,7 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
37
44
  readonly rootPlaybookId: string;
38
45
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
39
46
  }
40
- type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey>>;
47
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey | 'browser'>>;
41
48
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
42
49
  export interface ProgressChange {
43
50
  snapshot?: PlaybookCaptainShellSnapshot | null;
@@ -45,6 +52,10 @@ export interface ProgressChange {
45
52
  kind: PlaybookStepRecord['kind'] | 'preparation' | 'completion' | 'answer';
46
53
  runtimeSessionId: string;
47
54
  playbookId: string;
55
+ workerEvidence?: {
56
+ boundaryId: string;
57
+ playerId: string;
58
+ };
48
59
  };
49
60
  }
50
61
  export interface InterruptedReport {
@@ -54,6 +65,9 @@ export interface InterruptedReport {
54
65
  unresolvedEffects?: readonly PlaybookCaptainUnresolvedEffect[];
55
66
  }
56
67
  export interface PlaybookCaptainDeps {
68
+ onTurnAttachments?: (references: readonly SessionAssetRef[]) => void;
69
+ beginPreparationMedia?: (runtimeSessionId: string) => () => void;
70
+ resolveAttachments?: (references: readonly SessionAssetRef[], signal: AbortSignal) => Promise<readonly Attachment[]>;
57
71
  /** Stop the host's active turn, including admitted tool calls, on preparation expiry or a required save failure. */
58
72
  abortPreparation?: (reason?: string) => void;
59
73
  recordProgress?: (change: ProgressChange) => Promise<void>;
@@ -161,6 +175,7 @@ export interface PlaybookCaptainFrameSnapshot {
161
175
  readonly parentCallId?: string;
162
176
  readonly request?: string;
163
177
  readonly inputs?: readonly string[];
178
+ readonly attachments?: readonly SessionAssetRef[];
164
179
  readonly options: JsonValue;
165
180
  readonly roleBindings: Readonly<Record<string, string>>;
166
181
  readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
@@ -183,6 +198,7 @@ interface PlaybookCaptainShellSnapshotFields {
183
198
  readonly journal: number;
184
199
  };
185
200
  readonly journal: readonly PlaybookCaptainJournalRecord[];
201
+ readonly pendingAttachments?: readonly SessionAssetRef[];
186
202
  readonly lastAction?: 'respond' | 'start' | 'switch' | 'resume' | 'dismiss' | 'deliver' | 'runtime' | 'recover';
187
203
  readonly lastSettlementStatus?: 'ok' | 'rejected' | 'failed';
188
204
  }
@@ -238,6 +254,9 @@ export interface PlaybookCaptainSettlement {
238
254
  }
239
255
  /** tmux and headless front ends share this one durable Captain shell API. */
240
256
  export interface PlaybookCaptainShell extends Captain {
257
+ /** Bind already-owned evidence to the next visible Boss turn without changing its text. */
258
+ setTurnAttachments(references: readonly SessionAssetRef[]): void;
259
+ recordMediaEvidence(reference: SessionAssetRef, runtimeSessionId?: string): void;
241
260
  installRetainedGenerations(generations: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>): Promise<void>;
242
261
  exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
243
262
  exportSettlement(): PlaybookCaptainSettlement | undefined;