@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
@@ -12,7 +12,7 @@ The caller supplies a coding request together with any relevant context.
12
12
 
13
13
  A new coding intent follows either one direct implementation phase or an IR sequence consisting of a new-IR phase followed by one phase for each IR task.
14
14
  An existing IR follows one IR-task phase for each remaining unfinished task, starting with its next unfinished task.
15
- Each phase ends with one new Coder commit (owned by `code`).
15
+ Each completed phase ends with one new Coder commit (owned by `code`).
16
16
  After each `code`-owned commit, Captain shall call the `review` playbook and wait until it passes with no unsettled findings.
17
17
  Playbook `review` owns every review round and every review-fix commit.
18
18
  Do not rewrite any existing commit.
@@ -40,22 +40,37 @@ Plan affected spec updates before, with, or after their corresponding code chang
40
40
  For an existing IR, read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.
41
41
  Do not implement a later task in this phase.
42
42
  Mark the IR's progress and deliverables when relevant.
43
+ After implementing an IR task, identify it and state whether any IR task still has unfinished Coder work; if so identify the next unfinished task, otherwise state this was the final IR task's Coder work.
44
+ Report pending independent REVIEW, owner acceptance and later delivery/workshop steps separately; they imply an unfinished IR task only when the IR assigns that work to a remaining task.
45
+ Report only what the IR and completed work establish, in ordinary prose without a required marker.
43
46
  If the IR will be finished after this phase, double-check that all acceptance criteria are met.
44
47
 
45
48
  Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.
46
49
  ```
47
50
 
48
- At the start of every later IR-task phase, Captain shall relay to Coder the original caller input, the IR identity, and any relevant run results in quotes (`>`), along with the following instruction:
51
+ On each successful nested REVIEW, Captain shall freeze the previous phase review as one record from the canonical child result and the phase's accepted receipt-owned scope: exact phase kind (direct, new intent or IR task), phase outcome, CODE scope commit, REVIEW evaluated revision, and IR/task identity when present.
52
+ CODE scope commit and evaluated revision remain distinct because REVIEW may create fix commits.
53
+ No caller option, player prose or repository status text supplies this record.
54
+ The record survives permitted restoration and the current task's Boss-answer continuation, and a fresh run or explicit Boss interruption clears it.
55
+
56
+ At the start of every later IR-task phase, Captain shall relay to Coder the original caller input, the IR identity, any relevant run results, and that previous phase review as quoted evidence in quotes (`>`), along with the following instruction; when absent, the record reads "No accepted prior-phase review is available.":
49
57
 
50
58
  > Original request: <caller-input>
51
59
  > IR number: <ir-number>
52
60
  > Run results: <run-results>
61
+ > Previous phase review: <previous-phase-review>
53
62
 
54
63
  ```markdown
55
64
  Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.
56
65
  Do not implement a later task in this phase.
57
66
  Mark the IR's progress and deliverables when relevant.
67
+ After implementing an IR task, identify it and state whether any IR task still has unfinished Coder work; if so identify the next unfinished task, otherwise state this was the final IR task's Coder work.
68
+ Report pending independent REVIEW, owner acceptance and later delivery/workshop steps separately; they imply an unfinished IR task only when the IR assigns that work to a remaining task.
69
+ Report only what the IR and completed work establish, in ordinary prose without a required marker.
58
70
  If the IR will be finished after this phase, double-check that all acceptance criteria are met.
71
+ Use the quoted previous-phase review only for its exact recorded scope; committed pending-review text may predate that canonical result.
72
+ Before treating it as current review evidence, verify clean current HEAD equals its evaluatedRevision; a mismatch is not approval.
73
+ It does not replace independent review of this task or any new owner or release decision.
59
74
  ```
60
75
 
61
76
  At the start of *every* phase, Captain shall append the following instruction:
@@ -63,7 +78,10 @@ At the start of *every* phase, Captain shall append the following instruction:
63
78
  ```markdown
64
79
  Keep to the original intent and follow what it asks.
65
80
  Do not re-run tests or builds whose inputs have not changed since any previous reported run.
66
- Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.
81
+ If a required Boss answer prevents completion of the current phase or IR task, ask that question and return without committing.
82
+ Preserve permitted uncommitted work for the deferred continuation; do not create a partial or blocker-only commit to meet the one-commit requirement.
83
+ After the answer allows completion, make exactly one phase-owned commit before REVIEW.
84
+ When the phase is complete, make its minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.
67
85
  Make the commit message explain concisely what changed and why, including relevant verification.
68
86
  Identify every new commit you make.
69
87
  Credit every AI that contributed to this commit: Coder <coder-llm>.
@@ -73,9 +91,11 @@ Every new-intent phase has two semantic outcomes: direct implementation and new
73
91
  Every IR-task phase, including the first phase for an existing IR, identifies the IR and the implemented task, and has two semantic outcomes: more tasks and final task.
74
92
  The first phase therefore has four semantic outcomes — direct implementation, new IR, more tasks, and final task — plus the Boss question when the continued IR is ambiguous.
75
93
  Each semantic outcome requires affirmative support in Coder's result, but no phase transition shall depend on a fixed presentation format of Coder's reply.
94
+ The Boss-question outcome means that, at the end of this call, Coder has an unanswered clarifying question for Boss whose answer is required to complete the current coding phase. Earlier interim questions resolved or superseded by later affirmative current-phase completion, and prerequisites only for later review, acceptance or release stages, do not establish this outcome.
95
+ A commit statement alone does not establish semantic completion, and contradictory or insufficient evidence supports no forced commit outcome.
76
96
  Captain shall use the repository-effect receipt as the authoritative identity of the phase's new commit.
77
97
 
78
- At the end of every phase, Captain shall call playbook `review` and input the following in quotes (`>`):
98
+ At the end of every completed phase, Captain shall call playbook `review` and input the following in quotes (`>`):
79
99
 
80
100
  > Original intent: \<caller-input\>
81
101
  > Review scope: the commit \<code-commit\> from this coding phase and its resulting repository state.
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ /** Cancellation only needs the standard synchronous abort check. */
5
+ export interface AssetAbortSignal { throwIfAborted(): void; }
6
+ export type AssetId = `sha256:${string}`;
7
+ export interface SessionAssetRef {
8
+ readonly assetId: AssetId;
9
+ readonly byteLength: number;
10
+ readonly mimeType: string;
11
+ readonly name?: string;
12
+ }
13
+ export type AssetImport = (
14
+ | { readonly bytes: Uint8Array; readonly path?: never }
15
+ | { readonly path: string; readonly bytes?: never }
16
+ ) & { readonly mimeType: string; readonly name?: string; readonly signal?: AssetAbortSignal };
17
+ export interface AssetFile {
18
+ readonly assetId: AssetId;
19
+ /** Relative filename within this owner's asset directory. */
20
+ readonly path: string;
21
+ readonly byteLength: number;
22
+ readonly metadataPath: string;
23
+ }
24
+ export interface AssetReader {
25
+ readonly reference: SessionAssetRef;
26
+ read(options: { readonly offset: number; readonly length: number; readonly signal?: AssetAbortSignal }): Promise<Uint8Array>;
27
+ close(): Promise<void>;
28
+ }
29
+ export interface SessionTurnInput {
30
+ readonly text: string;
31
+ readonly attachments?: readonly SessionAssetRef[];
32
+ }
@@ -2149,6 +2149,7 @@ function sessionAgentFromHostAgent(agent, path) {
2149
2149
  }
2150
2150
  return {
2151
2151
  adapter: agent.adapter,
2152
+ ...(agent.browser === undefined ? {} : { browser: fastModeSelection(agent.browser, `${path}.browser`) }),
2152
2153
  model: tuningSelection(agent.model, `${path}.model`),
2153
2154
  effort: tuningSelection(agent.effort, `${path}.effort`),
2154
2155
  ...(agent.fastMode === undefined
@@ -2186,6 +2187,7 @@ export function projectHostAgent(agent, path = "agent") {
2186
2187
  const normalized = cloneJson(agent, path);
2187
2188
  return {
2188
2189
  adapter: normalized.adapter,
2190
+ ...(normalized.browser === undefined ? {} : { browser: fastModeSelection(normalized.browser, `${path}.browser`) }),
2189
2191
  ...(normalized.model?.kind === "value"
2190
2192
  ? { model: normalized.model.value }
2191
2193
  : {}),
@@ -2,6 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
4
  import { createHash } from 'node:crypto';
5
+ import { validateAssetRef } from '../session-assets.js';
5
6
  import { posix, win32 } from 'node:path';
6
7
  import { isDeepStrictEqual } from 'node:util';
7
8
  import {
@@ -16,7 +17,7 @@ export const EMPTY_REPLAY_SHA256 = createHash('sha256').digest('hex');
16
17
  export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
17
18
  const COMMON = ['schemaVersion', 'kind', 'sessionId', 'cwd', 'createdAt', 'updatedAt', 'state', 'replay', 'contextSeq'];
18
19
  const RECOVERY = ['structuralProjection', 'lastAppliedExecutionProjection', 'snapshot', 'effectLedger', 'unresolvedEffects'];
19
- const OPTIONAL = ['retainedGenerations', 'settledAbandonment'];
20
+ const OPTIONAL = ['retainedGenerations', 'settledAbandonment', 'assets'];
20
21
  const HEX = /^[0-9a-f]{64}$/;
21
22
  const clone = (value) => structuredClone(value);
22
23
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -44,11 +45,17 @@ export function validateReplayCheckpoint(value) {
44
45
  export function validateSessionManifest(value) {
45
46
  if (!object(value) || value.schemaVersion !== 7) throw new Error(`unsupported session manifest schema ${value?.schemaVersion}`);
46
47
  const history = value.state === 'history-only';
47
- exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? [] : OPTIONAL);
48
+ exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? ['assets'] : OPTIONAL);
48
49
  if (value.kind !== 'captain-session' || !SESSION_ID_PATTERN.test(value.sessionId) || !isRecordedAbsolutePath(value.cwd)) throw new Error('invalid session manifest identity');
49
50
  iso(value.createdAt); iso(value.updatedAt);
50
51
  if (Date.parse(value.updatedAt) < Date.parse(value.createdAt)) throw new Error('session update precedes creation');
51
52
  validateReplayCheckpoint(value.replay);
53
+ if (value.assets !== undefined) {
54
+ exact(value.assets, ['version', 'entries']);
55
+ if (value.assets.version !== 1 || !Array.isArray(value.assets.entries)) throw new Error('unsupported session asset manifest');
56
+ const ids = new Set();
57
+ for (const item of value.assets.entries) { const reference = validateAssetRef(item); if (ids.has(reference.assetId)) throw new Error('duplicate session asset reference'); ids.add(reference.assetId); }
58
+ }
52
59
  if (!(history && value.contextSeq === null) && (!Number.isSafeInteger(value.contextSeq) || value.contextSeq <= 0 || value.contextSeq > value.replay.seq)) throw new Error('invalid session context reference');
53
60
  if (history) {
54
61
  if (!nonempty(value.reason)) throw new Error('history-only session needs a reason');
@@ -60,7 +67,7 @@ export function validateSessionManifest(value) {
60
67
  return clone(value);
61
68
  }
62
69
  function recoveryFromManifestUnchecked(value) {
63
- const { replay, contextSeq, ...recovery } = value;
70
+ const { replay, contextSeq, assets, ...recovery } = value;
64
71
  return { ...recovery, schemaVersion: 6 };
65
72
  }
66
73
  export function recoveryFromManifest(value) {
@@ -68,9 +75,9 @@ export function recoveryFromManifest(value) {
68
75
  if (manifest.state === 'history-only') throw new Error(manifest.reason);
69
76
  return validateCaptainSessionRecord(recoveryFromManifestUnchecked(manifest));
70
77
  }
71
- export function manifestFromRecovery(value, replay, contextSeq) {
78
+ export function manifestFromRecovery(value, replay, contextSeq, assets) {
72
79
  const recovery = projectRecovery(validateCaptainSessionRecord(value));
73
- return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq });
80
+ return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq, ...(assets === undefined ? {} : { assets }) });
74
81
  }
75
82
  export function projectRecovery(value) {
76
83
  const source = clone(value);
@@ -7,6 +7,8 @@
7
7
  // presenter; it does not construct a registry runtime or PlaybookPorts itself.
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
+ import { workerReportExcerpt, workerRecoveryReportBlock } from "../worker-evidence.js";
11
+ import { createSessionMediaProjector } from "./session-media.js";
10
12
  import { attachSessionHints, validateSessionContext } from "./portable-codec.js";
11
13
  import { homedir } from "node:os";
12
14
  import { resolve } from "node:path";
@@ -995,6 +997,13 @@ export function restoreInterruptedProgress(record, ledger) {
995
997
  : `Captain preparation for ${name}: ${step.result.summary}`;
996
998
  return `${name} ${step.kind} call: ${step.result === undefined ? 'started; no result was saved' : step.result?.status === undefined ? 'result saved' : `returned ${step.result.status}`}.`;
997
999
  });
1000
+ const workerReports = changedBoundaries.flatMap((boundary) => {
1001
+ const completedStep = steps.find((step) => step.kind === 'player' &&
1002
+ step.workerEvidence?.boundaryId === boundary.boundaryId);
1003
+ if (!completedStep) return [];
1004
+ const report = workerReportExcerpt(boundary, completedStep.workerEvidence.playerId, completedStep.result.guard);
1005
+ return report === undefined ? [] : [report];
1006
+ });
998
1007
  const completedRoots = steps.filter((step) => step.kind === 'completion' && step.result.retention !== 'keep').map((step) => ({ kind: 'clear', rootPlaybookId: step.playbookId }));
999
1008
  const report = {
1000
1009
  retentionUpdates: completedRoots,
@@ -1008,7 +1017,7 @@ export function restoreInterruptedProgress(record, ledger) {
1008
1017
  ? completion && !saved.frames?.length
1009
1018
  ? 'The completed run is restored; no work was repeated.'
1010
1019
  : 'The run stopped. Its saved position is restored; no work was repeated. Choose how to continue. Before repeating unfinished work, confirm that the earlier worker has stopped and check any outside actions it may have taken.'
1011
- : 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts].join('\n\n'),
1020
+ : 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts, ...(workerReports.length ? [workerRecoveryReportBlock(workerReports)] : [])].join('\n\n'),
1012
1021
  };
1013
1022
  if (noWork) return { snapshot: base, report };
1014
1023
  if (saved) {
@@ -1068,6 +1077,7 @@ export async function createCaptainSessionHost({
1068
1077
  signal,
1069
1078
  graphs = [],
1070
1079
  initialVisible = [],
1080
+ approvalHandler,
1071
1081
  }) {
1072
1082
  if (restoreSnapshot !== undefined) await sessionLease.assertContinuable({ cwd, executionProjection: config });
1073
1083
  const hostCapabilities = await createRepositoryEffectCapabilities({
@@ -1115,23 +1125,30 @@ export async function createCaptainSessionHost({
1115
1125
  if (sourceSnapshot !== undefined && !interrupted && typeof sessionLease.consumeHints === "function") {
1116
1126
  sourceSnapshot = attachSessionHints(sourceSnapshot, await sessionLease.consumeHints());
1117
1127
  }
1128
+ let presentationAttachments = [];
1129
+ const mediaProjector = createSessionMediaProjector({ lease: sessionLease, onEvidence: (asset, origin) => shell.recordMediaEvidence(asset, origin.runtimeSessionId) });
1118
1130
  const bufferedRecords = [];
1119
1131
  let presentationReady = false;
1120
1132
  const presentationTurnOffset = restoreSnapshot?.sequences.turn ?? 0;
1133
+ const approvals = createHostApprovalScope(approvalHandler, presentationTurnOffset);
1121
1134
  const forwardRecord = async (record) => {
1135
+ if (record.type === 'turn_started' && presentationAttachments.length) record = { ...record, turn: { ...record.turn, attachments: presentationAttachments } };
1122
1136
  const projected = presentationTurnOffset > 0 && typeof record.turnId === "number"
1123
1137
  ? { ...record, turnId: record.turnId + presentationTurnOffset, ...(record.type === "turn_started" ? { turn: { ...record.turn, id: record.turn.id + presentationTurnOffset } } : {}) }
1124
1138
  : record;
1125
- for (const observer of observers ?? []) await observer.onRecord?.(projected);
1139
+ for (const value of await mediaProjector.project(projected)) for (const observer of observers ?? []) await observer.onRecord?.(value);
1126
1140
  };
1127
1141
  const bufferedObservers = [{ async onRecord(record) {
1128
1142
  if (!presentationReady) bufferedRecords.push(record);
1129
1143
  else await forwardRecord(record);
1130
1144
  } }];
1131
1145
  const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
1146
+ onTurnAttachments: (references) => { presentationAttachments = references; },
1147
+ beginPreparationMedia: (runtimeSessionId) => mediaProjector.beginPreparation(runtimeSessionId),
1132
1148
  abortPreparation: (reason) => host?.abortActiveTurn(typeof reason === 'string' ? reason : 'Captain preparation exceeded its 150-second limit'),
1133
1149
  loadModule,
1134
1150
  hostCapabilities,
1151
+ ...(typeof sessionLease.resolveAttachments === 'function' ? { resolveAttachments: (references, signal) => sessionLease.resolveAttachments(references, { signal }) } : {}),
1135
1152
  ...(typeof sessionLease.recordProgress === 'function' ? {
1136
1153
  recordProgress: (change) => sessionLease.recordProgress(change),
1137
1154
  } : {}),
@@ -1166,6 +1183,7 @@ export async function createCaptainSessionHost({
1166
1183
  })),
1167
1184
  cwd,
1168
1185
  observers: bufferedObservers,
1186
+ ...(approvals.handler ? { approvalHandler: approvals.handler } : {}),
1169
1187
  ...(signal ? { signal } : {}),
1170
1188
  ...(adapterImports ? { adapterImports } : {}),
1171
1189
  });
@@ -1193,9 +1211,13 @@ export async function createCaptainSessionHost({
1193
1211
  }
1194
1212
  presentationReady = true;
1195
1213
  for (const record of bufferedRecords) await forwardRecord(record);
1196
- if (interruptedReport) shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
1197
- return { shell, host, snapshot, reconcileRepositoryEffects, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
1214
+ if (interruptedReport) {
1215
+ shell.setTurnAttachments(interrupted.uncertain.attachments ?? []);
1216
+ shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
1217
+ }
1218
+ return { shell, host, snapshot, reconcileRepositoryEffects, cancelPendingApprovals: approvals.close, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
1198
1219
  } catch (error) {
1220
+ approvals.close();
1199
1221
  let cleanupError;
1200
1222
  try {
1201
1223
  if (host !== undefined) await host.dispose();
@@ -1213,6 +1235,48 @@ export async function createCaptainSessionHost({
1213
1235
  }
1214
1236
  }
1215
1237
 
1238
+ // Callback liveness belongs to the host, independently of durable turn state.
1239
+ // Closing this scope denies waits without cancelling ordinary graceful work.
1240
+ function createHostApprovalScope(handler, turnOffset) {
1241
+ const lifetime = new AbortController();
1242
+ return {
1243
+ close: () => lifetime.abort('session host closing'),
1244
+ handler: typeof handler !== 'function' ? undefined : (envelope, { signal }) => {
1245
+ if (lifetime.signal.aborted || signal.aborted) return Promise.resolve('deny');
1246
+ const controller = new AbortController();
1247
+ return new Promise((resolve, reject) => {
1248
+ let settled = false;
1249
+ const finish = (decision) => {
1250
+ if (settled) return;
1251
+ settled = true;
1252
+ signal.removeEventListener('abort', cancel);
1253
+ lifetime.signal.removeEventListener('abort', cancel);
1254
+ if (!envelope.request.choices.includes(decision)) controller.abort('Invalid approval decision');
1255
+ resolve(decision);
1256
+ };
1257
+ const fail = (error) => {
1258
+ if (settled) return;
1259
+ settled = true;
1260
+ signal.removeEventListener('abort', cancel);
1261
+ lifetime.signal.removeEventListener('abort', cancel);
1262
+ controller.abort(error);
1263
+ reject(error);
1264
+ };
1265
+ const cancel = () => {
1266
+ controller.abort(signal.aborted ? signal.reason : lifetime.signal.reason);
1267
+ finish('deny');
1268
+ };
1269
+ signal.addEventListener('abort', cancel, { once: true });
1270
+ lifetime.signal.addEventListener('abort', cancel, { once: true });
1271
+ Promise.resolve().then(() => {
1272
+ if (settled) return 'deny';
1273
+ return handler({ ...envelope, turnId: envelope.turnId + turnOffset }, { signal: controller.signal });
1274
+ }).then(finish, fail);
1275
+ });
1276
+ },
1277
+ };
1278
+ }
1279
+
1216
1280
  // PBCLI-55: both presentations install one authoritative retention map at the
1217
1281
  // same post-shell, pre-turn boundary. Fresh-session guarded initialization
1218
1282
  // publishes either the intentional empty boundary or the transferred map.
@@ -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
+ }