@sublang/playbook 3.1.0 → 5.0.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 (31) hide show
  1. package/README.md +64 -99
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +83 -9
  4. package/docs/configuration.md +5 -3
  5. package/package.json +7 -4
  6. package/reference/sdlc/captain.md +70 -83
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  10. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +464 -968
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +696 -993
  14. package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
  15. package/reference/sdlc/code.playbook/bin/playbook.js +54 -9
  16. package/reference/sdlc/code.playbook/bin/run.js +97 -0
  17. package/reference/sdlc/code.playbook/code.playbook.js +17 -0
  18. package/reference/sdlc/code.playbook/code.playbook.ts +17 -0
  19. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  20. package/reference/sdlc/code.playbook/playbook-captain.js +1784 -215
  21. package/reference/sdlc/code.playbook/playbook-captain.ts +2293 -330
  22. package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
  23. package/reference/sdlc/discuss.playbook/discuss.playbook.js +41 -9
  24. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +42 -9
  25. package/slc/gears2fsm.md +54 -2
  26. package/slc/link.md +293 -25
  27. package/src/runtime.d.ts +29 -1
  28. package/src/runtime.ts +47 -0
  29. package/src/xstate-playbook-runtime.d.ts +97 -5
  30. package/src/xstate-playbook-runtime.js +769 -29
  31. package/src/xstate-playbook-runtime.ts +962 -34
@@ -1,103 +1,193 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  //
4
- // slc link artifact
5
- // FSM path: ./captain.fsm.ts
6
- // Player binding: none (no delegated-player states)
7
- // Adjudication strategy: LLM-judge per Captain state
8
- // Boss-event mapping: deterministic ready entry; LLM-judge classification otherwise
9
-
10
- import PQueue from 'p-queue';
11
- import { createActor, fromPromise, type ActorRefFrom } from 'xstate';
4
+ // Generated by slc/link.md (FSM-to-Runtime linker).
5
+ // Source FSM: ./captain.fsm.ts
6
+ // Player bind: none (the session Captain declares no player behavior)
7
+ // Boss event: deterministic controller entry (slc/link.md §Boss-event
8
+ // mapping): every turn maps from the exact Boss text and the
9
+ // host's command-parse resolution supplied through the linked
10
+ // options' controller port — BOSS_TURN / PARSED_RESPOND /
11
+ // PARSED_ACTION / SHUTDOWN with no classifier judge call.
12
+ // Captain calls: hidden controller form (slc/link.md §Captain adjudication,
13
+ // DR-029): decision and closing-reply calls run
14
+ // `{ visibility: 'hidden' }`; the host's session-Captain
15
+ // wrapper owns the durable-conversation resume selection and
16
+ // pins the returned token (the runtime requests
17
+ // `resume: false` as the typed placeholder and its traces
18
+ // carry no resume member). The decision reply is `{ action,
19
+ // … }` control JSON validated here against the declared
20
+ // decision-state contract — never adjudicated through a
21
+ // judge call and never Boss presentation; controller prose
22
+ // reaches Boss only as host-validated captain speech through
23
+ // the host's presentation seam (cligent
24
+ // `CaptainContext.emitReply`).
25
+ // Contract: types imported and re-exported from
26
+ // @sublang/playbook/runtime (slc/link.md §Output); the
27
+ // shared createXStatePlaybookRuntime factory from
28
+ // @sublang/playbook/xstate-runtime interprets the FSM
29
+ // (DR-019). This module carries only the Captain-specific
30
+ // spec: options validation (catalog + controller port),
31
+ // deterministic entry mapping, the controller captain-call
32
+ // strategy with its single corrective re-ask (CAPPLAY-18),
33
+ // controller-port submission, and status formatting.
34
+ // Compat: spec.compat = { artifactSchema: 1, runtimeAbi: 1 }
35
+ // (DR-022; checked at construction by the loading engine).
12
36
 
37
+ import {
38
+ createXStatePlaybookRuntime,
39
+ defaultComposeCaptainPrompt,
40
+ normalizeError,
41
+ normalizeErrorCompact,
42
+ parseJudgeJson,
43
+ snapshotJsonValue,
44
+ RUNTIME_ABI,
45
+ type PlaybookActorOutput,
46
+ type PlaybookCaptainInput,
47
+ type ScheduledStatus,
48
+ type XStateCaptainStrategyRun,
49
+ type XStatePlaybookRuntimeSpec,
50
+ } from '../../../src/xstate-runtime.js';
13
51
  import {
14
52
  captainMachine,
15
53
  type CaptainInput,
16
54
  type CaptainOutput,
55
+ type DecisionAction,
17
56
  type EnabledPlaybook,
18
- type PlaybookInput,
57
+ type ParsedActingDecision,
58
+ type SettlementEvidence,
59
+ type SettlementReceiptEvidence,
19
60
  } from './captain.fsm.js';
20
-
21
61
  import type {
22
62
  CaptainCallOptions,
23
63
  CaptainResult,
24
64
  JsonValue,
65
+ NormalizedError,
66
+ PlayerCallOptions,
67
+ PlaybookCallRequest,
25
68
  PlaybookCallResult,
26
- PlaybookPorts,
27
- PlaybookRuntime,
28
- PlaybookRuntimeFactory,
69
+ PlaybookCallStart,
70
+ PlaybookControlReceipt,
71
+ PlaybookControlView,
72
+ PlaybookPendingCall,
29
73
  PlaybookRunResult,
74
+ PlaybookRuntimeSnapshot,
30
75
  PlaybookSession,
31
76
  PlaybookState,
77
+ PlaybookStateValue,
32
78
  PlaybookTraceEvent,
33
- } from '../../../src/runtime.js';
34
-
35
- import {
36
- assertJsonSafe,
37
- combineAbortSignals,
38
- createNestedPlaybookBridge,
39
- normalizeError,
40
- normalizePlaybookSnapshot,
41
- snapshotJsonValue,
42
- snapshotPlaybookSession,
43
- validateCaptainResult,
44
- waitForPlaybookQuiescence,
45
- } from '../../../src/xstate-runtime.js';
79
+ PlaybookTraceType,
80
+ PlaybookPorts,
81
+ PlaybookRuntime,
82
+ PlaybookRuntimeFactory,
83
+ PlayerResult,
84
+ } from '@sublang/playbook/runtime';
46
85
 
86
+ // Public contract re-exports (slc/link.md §Output): every linked playbook
87
+ // shares the one contract definition from @sublang/playbook/runtime.
47
88
  export type {
48
89
  CaptainCallOptions,
49
90
  CaptainResult,
50
91
  JsonValue,
51
92
  NormalizedError,
93
+ PlayerCallOptions,
52
94
  PlaybookCallRequest,
53
95
  PlaybookCallResult,
54
96
  PlaybookCallStart,
55
- PlaybookPorts,
97
+ PlaybookControlReceipt,
98
+ PlaybookControlView,
99
+ PlaybookPendingCall,
56
100
  PlaybookRunResult,
101
+ PlayerResult,
102
+ PlaybookPorts,
57
103
  PlaybookRuntime,
58
104
  PlaybookRuntimeFactory,
105
+ PlaybookRuntimeSnapshot,
59
106
  PlaybookSession,
60
107
  PlaybookState,
61
108
  PlaybookStateValue,
62
109
  PlaybookTraceEvent,
63
- PlayerCallOptions,
64
- PlayerResult,
65
- } from '../../../src/runtime.js';
110
+ PlaybookTraceType,
111
+ };
66
112
 
67
- export interface PlaybookRuntimeOptions {
68
- readonly enabledPlaybooks: readonly EnabledPlaybook[];
69
- }
113
+ // Artifact-declared controller types (slc/link.md §PlaybookRuntime contract:
114
+ // host-supplied port-shaped callbacks are linker-exposed option members whose
115
+ // types the artifact itself declares, so `PlaybookPorts` stays six members).
116
+ export type {
117
+ DecisionAction,
118
+ EnabledPlaybook,
119
+ ParsedActingDecision,
120
+ SettlementEvidence,
121
+ SettlementReceiptEvidence,
122
+ };
70
123
 
71
- type RootActor = ActorRefFrom<typeof captainMachine>;
124
+ /** One nonempty complete standalone request selected for `start` or `switch`. */
125
+ export type CaptainControllerInput = string;
72
126
 
73
- type BossEvent =
74
- | { readonly type: 'BOSS_INTENT'; readonly bossIntent: string }
75
- | {
76
- readonly type: 'BOSS_INTERRUPT';
77
- readonly targetId: 'routing';
78
- readonly bossIntent: string;
79
- }
127
+ /** One validated controller selection submitted through the port (DR-029). */
128
+ export type CaptainControllerSelection =
129
+ | { readonly action: 'respond'; readonly text: string }
80
130
  | {
81
- readonly type: 'BOSS_REPLY';
82
- readonly answer: string;
83
- readonly questionId?: string;
84
- };
85
-
86
- type BossMapping = BossEvent | { readonly type: 'NO_ACTION' } | undefined;
131
+ readonly action: 'start' | 'switch';
132
+ readonly playbookId: string;
133
+ /** Complete standalone request synthesized from the remembered Boss conversation. */
134
+ readonly input: CaptainControllerInput;
135
+ }
136
+ | { readonly action: 'dismiss' }
137
+ | { readonly action: 'deliver' }
138
+ | { readonly action: 'runtime'; readonly actionId: string };
139
+
140
+ /**
141
+ * The host's deterministic per-turn resolution (CAPTAIN-7 parse table):
142
+ * `undefined` sends the turn to the hidden decision call; `respond` routes
143
+ * the dedicated prose item; `action` injects the parse-resolved decision
144
+ * object; `shutdown` is the host teardown entry to the machine's one final
145
+ * state.
146
+ */
147
+ export type CaptainParsedResolution =
148
+ | { readonly kind: 'respond' }
149
+ | { readonly kind: 'action'; readonly decision: ParsedActingDecision }
150
+ | { readonly kind: 'shutdown' };
151
+
152
+ /**
153
+ * The host-supplied controller port (CAPPLAY-9, DR-029): one validated
154
+ * selection per Boss turn in, its settlement back as the only evidence of
155
+ * effects. The optional `resolveParsedTurn` member supplies the host's
156
+ * deterministic command-parse resolution for the entry mapping.
157
+ */
158
+ export interface CaptainControllerPort {
159
+ submit(
160
+ selection: CaptainControllerSelection,
161
+ signal: AbortSignal,
162
+ ): Promise<SettlementEvidence>;
163
+ resolveParsedTurn?(text: string): CaptainParsedResolution | undefined;
164
+ }
87
165
 
88
- type RuntimeSession = Omit<PlaybookSession, 'ports'> & {
89
- readonly ports: PlaybookPorts;
90
- };
166
+ export interface PlaybookRuntimeOptions {
167
+ readonly enabledPlaybooks: readonly EnabledPlaybook[];
168
+ /**
169
+ * The host-supplied controller port. Every Boss turn settles through it
170
+ * (CAPPLAY-9), so it is declared required. It was optional here only until
171
+ * the IR-036 task-4 shell rework landed; that rework has landed and the
172
+ * shell now supplies the port on every construction, so a declaration that
173
+ * still admitted its absence typechecked a runtime that cannot settle a
174
+ * single turn.
175
+ */
176
+ readonly controller: CaptainControllerPort;
177
+ }
91
178
 
92
- const CAPTAIN_OPTIONS: CaptainCallOptions = {
93
- visibility: 'visible',
94
- resume: false,
95
- allowedTools: [],
179
+ /**
180
+ * The validated option record the engine carries. It differs from the public
181
+ * declaration in exactly one place: `controller` is optional here, because a
182
+ * construction from untyped JavaScript can still omit it and the shape has to
183
+ * describe what validation actually accepts. Where that ends is
184
+ * `requireControllerPort` — the first Boss turn, with a named error rather
185
+ * than a silent one.
186
+ */
187
+ type ValidatedCaptainOptions = Omit<PlaybookRuntimeOptions, 'controller'> & {
188
+ readonly controller?: CaptainControllerPort;
96
189
  };
97
190
 
98
- const CONTINUATION_PREAMBLE =
99
- 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
100
-
101
191
  function assertNonEmptyString(value: unknown, label: string): string {
102
192
  if (typeof value !== 'string' || value.trim().length === 0) {
103
193
  throw new TypeError(`${label} must be a non-empty string`);
@@ -113,95 +203,33 @@ function isRecord(value: unknown): value is Record<string, unknown> {
113
203
  return prototype === Object.prototype || prototype === null;
114
204
  }
115
205
 
116
- function omitUndefined<T extends Record<string, unknown>>(value: T): JsonValue {
117
- const copy: Record<string, JsonValue> = {};
118
- for (const [key, entry] of Object.entries(value)) {
119
- if (entry !== undefined) {
120
- assertJsonSafe(entry, key);
121
- copy[key] = snapshotJsonValue(entry, key);
122
- }
123
- }
124
- return snapshotJsonValue(copy);
125
- }
126
-
127
- function stableJson(value: unknown): string {
128
- const json = snapshotJsonValue(value);
129
- return JSON.stringify(sortJson(json));
130
- }
131
-
132
- function sortJson(value: JsonValue): JsonValue {
133
- if (Array.isArray(value)) return Object.freeze(value.map((entry) => sortJson(entry)));
134
- if (value && typeof value === 'object') {
135
- const record = value as { readonly [key: string]: JsonValue };
136
- const sorted: Record<string, JsonValue> = {};
137
- for (const key of Object.keys(value).sort()) {
138
- sorted[key] = sortJson(record[key]);
139
- }
140
- return Object.freeze(sorted);
141
- }
142
- return value;
143
- }
144
-
145
- function replacePlaceholders(template: string, replacements: ReadonlyMap<string, string>): string {
146
- return template.replace(/<[^>\n]+>/g, (placeholder) => replacements.get(placeholder) ?? placeholder);
147
- }
148
-
149
- function continuationPrefix(input: {
150
- readonly pendingBossQuestion?: { readonly question: string };
151
- readonly bossReply?: string;
152
- }): string {
153
- if (!input.pendingBossQuestion || !input.bossReply) return '';
154
- return [
155
- CONTINUATION_PREAMBLE,
156
- '',
157
- 'Boss question:',
158
- input.pendingBossQuestion.question,
159
- '',
160
- 'Boss reply:',
161
- input.bossReply,
162
- '',
163
- '',
164
- ].join('\n');
165
- }
166
-
167
- export function composeCaptainPrompt(input: CaptainInput): string {
168
- const replacements = new Map<string, string>();
169
- replacements.set('<boss-intent>', input.bossIntent);
170
- replacements.set('<enabled-playbooks>', stableJson(input.enabledPlaybooks));
171
- if (input.remainingPlan !== undefined) {
172
- replacements.set('<remaining-plan>', stableJson(input.remainingPlan));
173
- }
174
- if (input.completedCallResults !== undefined) {
175
- replacements.set('<completed-call-results>', stableJson(input.completedCallResults));
176
- }
177
- return `${continuationPrefix(input)}${replacePlaceholders(input.prompt, replacements)}`;
178
- }
179
-
180
- export function composePlayerPrompt(input: {
181
- readonly prompt: string;
182
- readonly pendingBossQuestion?: { readonly question: string };
183
- readonly bossReply?: string;
184
- }): string {
185
- return `${continuationPrefix(input)}${input.prompt}`;
186
- }
187
-
188
- function validateEnabledPlaybooks(value: readonly EnabledPlaybook[]): readonly EnabledPlaybook[] {
206
+ function validateEnabledPlaybooks(
207
+ value: unknown,
208
+ ): readonly EnabledPlaybook[] {
189
209
  if (!Array.isArray(value)) {
190
210
  throw new TypeError('enabledPlaybooks must be an array');
191
211
  }
192
212
  const ids = new Set<string>();
193
213
  return Object.freeze(
194
- value.map((entry, index) => {
214
+ value.map((entry: unknown, index) => {
195
215
  if (!isRecord(entry)) {
196
216
  throw new TypeError(`enabledPlaybooks[${index}] must be an object`);
197
217
  }
198
218
  const keys = Object.keys(entry).sort();
199
219
  if (keys.join('\0') !== ['command', 'id', 'intent'].join('\0')) {
200
- throw new TypeError(`enabledPlaybooks[${index}] must contain exactly id, command, and intent`);
220
+ throw new TypeError(
221
+ `enabledPlaybooks[${index}] must contain exactly id, command, and intent`,
222
+ );
201
223
  }
202
224
  const id = assertNonEmptyString(entry.id, `enabledPlaybooks[${index}].id`);
203
- const command = assertNonEmptyString(entry.command, `enabledPlaybooks[${index}].command`);
204
- const intent = assertNonEmptyString(entry.intent, `enabledPlaybooks[${index}].intent`);
225
+ const command = assertNonEmptyString(
226
+ entry.command,
227
+ `enabledPlaybooks[${index}].command`,
228
+ );
229
+ const intent = assertNonEmptyString(
230
+ entry.intent,
231
+ `enabledPlaybooks[${index}].intent`,
232
+ );
205
233
  if (ids.has(id)) {
206
234
  throw new TypeError(`enabledPlaybooks id ${id} is duplicated`);
207
235
  }
@@ -211,934 +239,609 @@ function validateEnabledPlaybooks(value: readonly EnabledPlaybook[]): readonly E
211
239
  );
212
240
  }
213
241
 
214
- function parseJsonObjectLoose(text: string): Record<string, unknown> | undefined {
215
- const source = text;
216
- for (let start = 0; start < source.length; start += 1) {
217
- if (source[start] !== '{') continue;
218
- const bounded = boundedJsonCandidate(source, start);
219
- const candidates = bounded ? [bounded, bounded.replace(/,\s*([}\]])/g, '$1')] : [repairJsonSuffix(source.slice(start))];
220
- for (const candidate of candidates) {
221
- try {
222
- const parsed: unknown = JSON.parse(candidate);
223
- if (isRecord(parsed)) return parsed;
224
- } catch {
225
- // Try the next candidate at the same object boundary.
226
- }
227
- }
242
+ function validateControllerPort(value: unknown): CaptainControllerPort {
243
+ if (value === null || typeof value !== 'object') {
244
+ throw new TypeError('options.controller must be an object');
228
245
  }
229
- return undefined;
230
- }
231
-
232
- function boundedJsonCandidate(source: string, start: number): string | undefined {
233
- let depth = 0;
234
- let inString = false;
235
- let escaped = false;
236
- for (let index = start; index < source.length; index += 1) {
237
- const char = source[index];
238
- if (inString) {
239
- if (escaped) escaped = false;
240
- else if (char === '\\') escaped = true;
241
- else if (char === '"') inString = false;
242
- continue;
243
- }
244
- if (char === '"') inString = true;
245
- else if (char === '{' || char === '[') depth += 1;
246
- else if (char === '}' || char === ']') {
247
- depth -= 1;
248
- if (depth === 0) return source.slice(start, index + 1);
249
- }
246
+ const record = value as Record<string, unknown>;
247
+ if (typeof record.submit !== 'function') {
248
+ throw new TypeError('options.controller.submit must be a function');
250
249
  }
251
- return undefined;
252
- }
253
-
254
- function repairJsonSuffix(source: string): string {
255
- let repaired = source.replace(/,\s*$/g, '');
256
- let inString = false;
257
- let escaped = false;
258
- const stack: string[] = [];
259
- for (const char of repaired) {
260
- if (inString) {
261
- if (escaped) escaped = false;
262
- else if (char === '\\') escaped = true;
263
- else if (char === '"') inString = false;
264
- continue;
265
- }
266
- if (char === '"') inString = true;
267
- else if (char === '{') stack.push('}');
268
- else if (char === '[') stack.push(']');
269
- else if (char === '}' || char === ']') stack.pop();
250
+ if (
251
+ record.resolveParsedTurn !== undefined &&
252
+ typeof record.resolveParsedTurn !== 'function'
253
+ ) {
254
+ throw new TypeError(
255
+ 'options.controller.resolveParsedTurn must be a function when present',
256
+ );
270
257
  }
271
- if (inString) repaired += '"';
272
- while (stack.length > 0) repaired += stack.pop();
273
- return repaired.replace(/,\s*([}\]])/g, '$1');
258
+ return value as CaptainControllerPort;
274
259
  }
275
260
 
276
- function requiredOutputFields(description: string): readonly string[] {
277
- const marker = description.match(/Output shall include\s+(.+)$/);
278
- if (!marker) return [];
279
- const fields: string[] = [];
280
- const seen = new Set<string>();
281
- const regex = /`([^`]+)`/g;
282
- let match: RegExpExecArray | null;
283
- while ((match = regex.exec(marker[1])) !== null) {
284
- const name = match[1].split(':', 1)[0]?.trim();
285
- if (name && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !seen.has(name)) {
286
- seen.add(name);
287
- fields.push(name);
288
- }
289
- }
290
- return fields;
261
+ function snapshotCaptainOptions(value: unknown): ValidatedCaptainOptions {
262
+ if (!isRecord(value)) {
263
+ throw new TypeError('Captain runtime options must be an object');
264
+ }
265
+ const allowed = new Set(['enabledPlaybooks', 'controller']);
266
+ for (const key of Object.keys(value)) {
267
+ if (!allowed.has(key)) {
268
+ throw new TypeError(`Captain runtime options.${key} is not declared`);
269
+ }
270
+ }
271
+ const enabledPlaybooks = validateEnabledPlaybooks(value.enabledPlaybooks);
272
+ const controller =
273
+ value.controller === undefined
274
+ ? undefined
275
+ : validateControllerPort(value.controller);
276
+ return Object.freeze({
277
+ enabledPlaybooks,
278
+ ...(controller === undefined ? {} : { controller }),
279
+ });
291
280
  }
292
281
 
293
- function makeJudgePrompt(input: CaptainInput, visibleText: string): string {
294
- return [
295
- 'Adjudicate the direct Captain output for this FSM state.',
296
- `State id: ${input.stateId}`,
297
- `Source item: ${input.sourceItem}`,
298
- '',
299
- 'Visible Captain output:',
300
- visibleText,
301
- '',
302
- 'Result keys and descriptions:',
303
- ...Object.entries(input.result).map(([key, description]) => `- ${key}: ${description}`),
304
- '',
305
- 'Return one JSON object with exactly one declared guard.',
306
- 'For direct Captain question or response guards, do not include question or response; the runtime injects the visible text.',
307
- ].join('\n');
308
- }
282
+ // ---------------------------------------------------------------------------
283
+ // Deterministic controller entry mapping (slc/link.md §Boss-event mapping):
284
+ // the exact Boss text rides the runtime-owned `bossText` field; the host's
285
+ // parse resolution selects the hub entry arm; no classifier judge call runs.
286
+ // ---------------------------------------------------------------------------
309
287
 
310
- function adjudicateCaptainOutput(input: CaptainInput, visibleText: string, judgeText: string): CaptainOutput {
311
- const parsed = parseJsonObjectLoose(judgeText);
312
- if (!parsed) throw new Error('adjudicator reply did not contain a JSON object');
313
- const guard = parsed.guard;
314
- if (typeof guard !== 'string' || !(guard in input.result)) {
315
- throw new Error(`adjudicator selected undeclared guard ${String(guard)}`);
316
- }
317
- const allowed = new Set(['guard']);
318
- for (const field of requiredOutputFields(input.result[guard] ?? '')) {
319
- if (field !== 'question' && field !== 'response') allowed.add(field);
288
+ function validateParsedActingDecision(value: unknown): ParsedActingDecision {
289
+ if (!isRecord(value)) {
290
+ throw new TypeError('parse-resolved decision must be an object');
320
291
  }
321
- for (const key of Object.keys(parsed)) {
322
- if (!allowed.has(key)) throw new Error(`adjudicator supplied undeclared field ${key}`);
323
- }
324
- if (guard === 'question' || guard === 'followUpQuestion' || guard === 'needsBossReply') {
325
- return { guard, question: visibleText } as CaptainOutput;
292
+ if (value.action === 'deliver') {
293
+ if (Object.keys(value).length !== 1) {
294
+ throw new TypeError('a parse-resolved deliver decision carries no payload');
295
+ }
296
+ return { action: 'deliver' };
326
297
  }
327
- if (guard === 'final') {
328
- return { guard, response: visibleText };
298
+ if (value.action !== 'start' && value.action !== 'switch') {
299
+ throw new TypeError(
300
+ `parse-resolved decision names unknown action ${String(value.action)}`,
301
+ );
329
302
  }
330
- if (guard === 'delegation' || guard === 'continuing') {
331
- const missing = ['remainingPlan', 'nextPlaybookId', 'nextPlaybookInput'].filter((field) => !(field in parsed));
332
- if (missing.length > 0) {
333
- throw new Error(`adjudicator omitted required field ${missing.join(', ')}`);
303
+ const allowed = new Set(['action', 'playbookId', 'input']);
304
+ for (const key of Object.keys(value)) {
305
+ if (!allowed.has(key)) {
306
+ throw new TypeError(`parse-resolved decision carries undeclared ${key}`);
334
307
  }
335
- const remainingPlan = snapshotJsonValue(parsed.remainingPlan, 'remainingPlan');
336
- if (!Array.isArray(remainingPlan)) throw new Error('adjudicator remainingPlan must be a JSON array');
337
- return {
338
- guard,
339
- remainingPlan,
340
- nextPlaybookId: assertNonEmptyString(parsed.nextPlaybookId, 'nextPlaybookId'),
341
- nextPlaybookInput: assertNonEmptyString(parsed.nextPlaybookInput, 'nextPlaybookInput'),
342
- } as CaptainOutput;
343
308
  }
344
- throw new Error(`adjudicator selected unsupported guard ${guard}`);
309
+ return {
310
+ action: value.action,
311
+ playbookId: assertNonEmptyString(
312
+ value.playbookId,
313
+ 'parse-resolved decision playbookId',
314
+ ),
315
+ input: assertNonEmptyString(value.input, 'parse-resolved decision input'),
316
+ };
345
317
  }
346
318
 
347
- function validateClassifier(text: string, bossText: string, pendingQuestionId: string | undefined): BossMapping {
348
- const parsed = parseJsonObjectLoose(text);
349
- if (!parsed) return undefined;
350
- const type = parsed.type;
351
- if (type === 'NO_ACTION') {
352
- if (Object.keys(parsed).length !== 1) return undefined;
353
- return { type: 'NO_ACTION' };
354
- }
355
- if (type === 'BOSS_INTENT') {
356
- if (Object.keys(parsed).length !== 1) return undefined;
357
- return { type: 'BOSS_INTENT', bossIntent: bossText };
358
- }
359
- if (type === 'BOSS_INTERRUPT') {
360
- if (Object.keys(parsed).sort().join('\0') !== ['targetId', 'type'].join('\0')) return undefined;
361
- if (parsed.targetId !== 'routing') return undefined;
362
- return { type: 'BOSS_INTERRUPT', targetId: 'routing', bossIntent: bossText };
363
- }
364
- if (type === 'BOSS_REPLY') {
365
- const keys = Object.keys(parsed).sort();
366
- if (keys.join('\0') !== ['questionId', 'type'].join('\0') && keys.join('\0') !== 'type') return undefined;
367
- const questionId = parsed.questionId === undefined ? pendingQuestionId : parsed.questionId;
368
- if (questionId !== pendingQuestionId || typeof questionId !== 'string') return undefined;
369
- return { type: 'BOSS_REPLY', answer: bossText, questionId };
319
+ function classifyControllerTurn(
320
+ text: string,
321
+ _ports: PlaybookPorts,
322
+ _signal: AbortSignal,
323
+ _snapshotOrState: unknown,
324
+ _boundary?: unknown,
325
+ options?: ValidatedCaptainOptions,
326
+ ): Promise<Record<string, unknown> | undefined> {
327
+ const resolution = options?.controller?.resolveParsedTurn?.(text);
328
+ if (resolution === undefined) {
329
+ return Promise.resolve({ type: 'BOSS_TURN', bossText: text });
330
+ }
331
+ if (!isRecord(resolution)) {
332
+ throw new TypeError('controller parse resolution must be an object');
333
+ }
334
+ const widened = resolution as {
335
+ readonly kind?: unknown;
336
+ readonly decision?: unknown;
337
+ };
338
+ switch (widened.kind) {
339
+ case 'respond':
340
+ return Promise.resolve({ type: 'PARSED_RESPOND', bossText: text });
341
+ case 'action':
342
+ return Promise.resolve({
343
+ type: 'PARSED_ACTION',
344
+ bossText: text,
345
+ decision: validateParsedActingDecision(widened.decision),
346
+ });
347
+ case 'shutdown':
348
+ return Promise.resolve({ type: 'SHUTDOWN' });
349
+ default:
350
+ throw new TypeError(
351
+ `controller parse resolution names unknown kind ${String(widened.kind)}`,
352
+ );
370
353
  }
371
- return undefined;
372
354
  }
373
355
 
374
- function classifierPrompt(text: string, state: PlaybookState, pending: unknown): string {
356
+ // ---------------------------------------------------------------------------
357
+ // Decision-reply validation (CAPPLAY-18): the hidden decision call's reply is
358
+ // `{ action, … }` control JSON over the closed set, validated against the
359
+ // declared decision-state contract — known action, required payload fields,
360
+ // catalog membership, never this Captain playbook itself as a target. A
361
+ // malformed reply earns exactly one corrective call appending the rejection
362
+ // reason and the restated reply contract; a second malformed reply settles
363
+ // the turn as a recoverable hub return with no action executed.
364
+ // ---------------------------------------------------------------------------
365
+
366
+ const RESTATED_REPLY_CONTRACT = [
367
+ 'Reply again with exactly one JSON object `{ "action": …, … }` and no other text, selecting exactly one action from the closed set `respond` | `start` | `switch` | `dismiss` | `deliver` | `runtime`:',
368
+ '`{ "action": "respond", "text": … }`, `{ "action": "start", "playbookId": …, "input": … }`, `{ "action": "switch", "playbookId": …, "input": … }`, `{ "action": "dismiss" }`, `{ "action": "deliver" }`, or `{ "action": "runtime", "actionId": … }`; every `input` is one nonempty complete standalone request.',
369
+ ].join('\n');
370
+
371
+ function correctiveDecisionPrompt(prompt: string, reason: string): string {
375
372
  return [
376
- 'Classify this Boss message for the Captain playbook FSM.',
377
- '',
378
- 'Boss message:',
379
- text,
380
- '',
381
- 'Current state:',
382
- stableJson(state),
383
- '',
384
- 'Pending Boss question:',
385
- stableJson(pending ?? null),
373
+ prompt,
386
374
  '',
387
- 'Return JSON only. Allowed objects are {"type":"BOSS_REPLY","questionId":"routing-or-reassessing"}, {"type":"BOSS_INTERRUPT","targetId":"routing"}, {"type":"BOSS_INTENT"}, or {"type":"NO_ACTION"}.',
375
+ `Your previous control reply was rejected: ${reason}.`,
376
+ RESTATED_REPLY_CONTRACT,
388
377
  ].join('\n');
389
378
  }
390
379
 
391
- function stateFromSnapshot(actor: RootActor, pendingCall?: { readonly callId: string; readonly playbookId: string; readonly childSessionId: string }): PlaybookState {
392
- return normalizePlaybookSnapshot(actor.getSnapshot(), { pendingCall });
393
- }
394
-
395
- function resultFromState(
396
- state: PlaybookState,
397
- output: JsonValue | undefined,
398
- pendingCall?: { readonly callId: string; readonly playbookId: string; readonly childSessionId: string },
399
- error?: unknown,
400
- ): PlaybookRunResult {
401
- if (pendingCall) return { outcome: 'suspended', state, pendingCall };
402
- if (state.status === 'done') {
403
- return output === undefined ? { outcome: 'terminal', state } : { outcome: 'terminal', state, output };
404
- }
405
- if (state.stateId === 'failed') {
406
- return error === undefined ? { outcome: 'failed', state } : { outcome: 'failed', state, error: normalizeError(error) };
407
- }
408
- return { outcome: 'quiescent', state };
409
- }
410
-
411
- function isAbortLikeError(error: unknown): boolean {
412
- return normalizeError(error).name === 'AbortError';
413
- }
414
-
415
- function isSignalAbort(error: unknown, signal: AbortSignal): boolean {
416
- return signal.aborted && error === signal.reason;
417
- }
418
-
419
- class CaptainPlaybookRuntime implements PlaybookRuntime {
420
- private readonly enabledPlaybooks: readonly EnabledPlaybook[];
421
- private readonly emissionQueue = new PQueue({ concurrency: 1 });
422
- private readonly captainLane = new PQueue({ concurrency: 1 });
423
- private session: RuntimeSession | undefined;
424
- private actor: RootActor | undefined;
425
- private nestedBridge: ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>> | undefined;
426
- private sequence = 0;
427
- private turnId = 0;
428
- private callId = 0;
429
- private boundaryTurnId: number | undefined;
430
- private readonly playbookCallTurnIds = new Map<string, number>();
431
- private activeBoundarySignal: AbortSignal | undefined;
432
- private activeTurn: Promise<PlaybookRunResult> | undefined;
433
- private disposing: Promise<void> | undefined;
434
- private disposed = false;
435
- private terminallyDisposedBeforeInit = false;
436
- private disposalTraceEmitted = false;
437
- private initializing = false;
438
- private initializationDone: Promise<void> | undefined;
439
- private resolveInitializationDone: (() => void) | undefined;
440
- private latchedControlError: unknown;
441
- private suppressInspection = false;
442
- private previousState: PlaybookState | undefined;
443
-
444
- constructor(options: PlaybookRuntimeOptions) {
445
- this.enabledPlaybooks = validateEnabledPlaybooks(options.enabledPlaybooks);
446
- }
447
-
448
- async init(session: PlaybookSession): Promise<void> {
449
- if (this.session || this.actor) throw new Error('playbook runtime is already initialized');
450
- if (this.disposed || this.terminallyDisposedBeforeInit || this.disposing) throw new Error('playbook runtime is disposed');
451
- this.initializing = true;
452
- this.initializationDone = new Promise((resolve) => {
453
- this.resolveInitializationDone = resolve;
454
- });
455
- this.disposalTraceEmitted = false;
456
- let actor: RootActor | undefined;
457
- let initialState: PlaybookState | undefined;
458
- try {
459
- const captured = snapshotPlaybookSession(session);
460
- this.session = captured;
461
- this.nestedBridge = this.createBridge(captured);
462
- actor = this.createActor(captured, this.nestedBridge);
463
- this.actor = actor;
464
- initialState = stateFromSnapshot(actor);
465
- this.previousState = initialState;
466
- await this.trace('session.started', omitUndefined({ state: initialState, stateId: initialState.stateId }));
467
- await this.drain();
468
- actor.start();
469
- await this.drain();
470
- } catch (error) {
471
- this.suppressInspection = true;
472
- actor?.stop();
473
- if (initialState && !this.disposalTraceEmitted) await this.bestEffortDisposeTrace(initialState);
474
- this.session = undefined;
475
- this.actor = undefined;
476
- this.nestedBridge = undefined;
477
- this.sequence = 0;
478
- this.turnId = 0;
479
- this.callId = 0;
480
- this.latchedControlError = undefined;
481
- this.previousState = undefined;
482
- this.suppressInspection = false;
483
- throw error;
484
- } finally {
485
- this.initializing = false;
486
- this.resolveInitializationDone?.();
487
- this.resolveInitializationDone = undefined;
488
- }
380
+ type DecisionReplyReading =
381
+ | { readonly selection: CaptainControllerSelection; readonly reason?: undefined }
382
+ | { readonly selection?: undefined; readonly reason: string };
383
+
384
+ function readDecisionReply(
385
+ reply: string,
386
+ options: ValidatedCaptainOptions,
387
+ selfPlaybookId: string,
388
+ declaredActions: ReadonlySet<string>,
389
+ ): DecisionReplyReading {
390
+ let parsed: unknown;
391
+ try {
392
+ parsed = parseJudgeJson(reply);
393
+ } catch {
394
+ return { reason: 'the reply contains no recoverable JSON object' };
395
+ }
396
+ if (!isRecord(parsed)) {
397
+ return { reason: 'the reply is not a JSON object' };
398
+ }
399
+ const action = parsed.action;
400
+ if (typeof action !== 'string' || !declaredActions.has(action)) {
401
+ return {
402
+ reason: `the reply names no known action (got ${JSON.stringify(action ?? null)})`,
403
+ };
489
404
  }
490
-
491
- async handleBossInput(turn: { text: string; signal: AbortSignal }): Promise<PlaybookRunResult> {
492
- if (this.activeTurn) throw new Error('playbook runtime already has an active boundary');
493
- if (this.disposing || this.disposed) throw new Error('playbook runtime is disposing');
494
- const run = this.handleBossInputInner(turn);
495
- this.activeTurn = run;
496
- try {
497
- return await run;
498
- } catch (error) {
499
- if (isSignalAbort(error, turn.signal)) {
500
- const actor = this.actor;
501
- const bridge = this.nestedBridge;
502
- const snapshot = actor
503
- ? await waitForPlaybookQuiescence(actor, { pendingCalls: bridge })
504
- : undefined;
505
- const state = snapshot
506
- ? normalizePlaybookSnapshot(snapshot, { pendingCall: bridge?.getPendingCall() })
507
- : { value: 'failed', activeStateIds: ['failed'], tags: ['playbook.parked'], status: 'active', quiescent: true, stateId: 'failed' } satisfies PlaybookState;
508
- try {
509
- await this.drain();
510
- } catch {
511
- // The signal-driven abort remains the public outcome.
512
- }
513
- return { outcome: 'aborted', state, error: normalizeError(error) };
405
+ const requireKeys = (
406
+ required: readonly string[],
407
+ tolerated: readonly string[] = [],
408
+ ): string | undefined => {
409
+ for (const key of required) {
410
+ if (!(key in parsed)) {
411
+ return `the ${action} selection omits required field \`${key}\``;
514
412
  }
515
- throw error;
516
- } finally {
517
- this.activeTurn = undefined;
518
- const error = this.latchedControlError;
519
- this.latchedControlError = undefined;
520
- const aborted = this.activeBoundarySignal?.aborted === true;
521
- this.activeBoundarySignal = undefined;
522
- this.boundaryTurnId = undefined;
523
- if (error && (!aborted || !isAbortLikeError(error))) throw error;
524
413
  }
525
- }
526
-
527
- async resumePlaybookCall(input: { callId: string; result: PlaybookCallResult; signal: AbortSignal }): Promise<PlaybookRunResult> {
528
- if (this.activeTurn) throw new Error('playbook runtime already has an active boundary');
529
- if (this.disposing || this.disposed) throw new Error('playbook runtime is disposing');
530
- const run = this.resumePlaybookCallInner(input);
531
- this.activeTurn = run;
532
- try {
533
- return await run;
534
- } finally {
535
- this.activeTurn = undefined;
536
- const error = this.latchedControlError;
537
- this.latchedControlError = undefined;
538
- const aborted = this.activeBoundarySignal?.aborted === true;
539
- this.activeBoundarySignal = undefined;
540
- this.boundaryTurnId = undefined;
541
- if (error && (!aborted || !isAbortLikeError(error))) throw error;
542
- }
543
- }
544
-
545
- dispose(): Promise<void> {
546
- if (this.activeTurn) return Promise.reject(new Error('cannot dispose during an active boundary'));
547
- if (this.disposing) return this.disposing;
548
- if (!this.initializing && !this.session && !this.actor && !this.disposed) {
549
- this.terminallyDisposedBeforeInit = true;
550
- this.disposed = true;
551
- this.disposing = Promise.resolve();
552
- return this.disposing;
553
- }
554
- this.disposing = this.disposeInner();
555
- return this.disposing;
556
- }
557
-
558
- private async handleBossInputInner(turn: { text: string; signal: AbortSignal }): Promise<PlaybookRunResult> {
559
- const actor = this.requireActor();
560
- const nestedBridge = this.requireBridge();
561
- const currentTurnId = this.nextTurnId();
562
- this.boundaryTurnId = currentTurnId;
563
- this.activeBoundarySignal = turn.signal;
564
- await this.trace('boss.input.received', { text: turn.text }, currentTurnId);
565
- const state = stateFromSnapshot(actor, nestedBridge.getPendingCall());
566
- let event: BossMapping;
567
- if (turn.text.trim().length === 0) {
568
- const result: PlaybookRunResult = { outcome: 'no-action', state };
569
- await this.traceSettled(result, currentTurnId);
570
- await this.drain();
571
- return result;
414
+ for (const key of Object.keys(parsed)) {
415
+ if (
416
+ key !== 'action' &&
417
+ !required.includes(key) &&
418
+ !tolerated.includes(key)
419
+ ) {
420
+ return `the ${action} selection carries undeclared field \`${key}\``;
421
+ }
572
422
  }
573
- if (state.stateId === 'ready' || state.stateId === 'failed') {
574
- event = { type: 'BOSS_INTENT', bossIntent: turn.text };
575
- } else {
576
- try {
577
- event = await this.classifyBossInput(turn.text, state, turn.signal);
578
- } catch (error) {
579
- if (isSignalAbort(error, turn.signal)) {
580
- const result: PlaybookRunResult = { outcome: 'aborted', state, error: normalizeError(error) };
581
- await this.traceSettled(result, currentTurnId);
582
- await this.drain();
583
- return result;
584
- }
585
- await this.trace(
586
- 'boss.input.settled',
587
- omitUndefined({
588
- outcome: 'no-action',
589
- state,
590
- stateId: state.stateId,
591
- error: normalizeError(error),
592
- }),
593
- currentTurnId,
594
- );
595
- await this.drain();
596
- throw error;
423
+ return undefined;
424
+ };
425
+ const nonEmpty = (key: string): string | undefined =>
426
+ typeof parsed[key] === 'string' && parsed[key].trim().length > 0
427
+ ? undefined
428
+ : `the ${action} selection's \`${key}\` must be a non-empty string`;
429
+ switch (action) {
430
+ case 'respond': {
431
+ const shape = requireKeys(['text']) ?? nonEmpty('text');
432
+ if (shape !== undefined) return { reason: shape };
433
+ return { selection: { action, text: parsed.text as string } };
434
+ }
435
+ case 'start':
436
+ case 'switch': {
437
+ const shape =
438
+ requireKeys(['playbookId', 'input']) ??
439
+ nonEmpty('playbookId') ??
440
+ nonEmpty('input');
441
+ if (shape !== undefined) return { reason: shape };
442
+ const playbookId = parsed.playbookId as string;
443
+ if (playbookId === selfPlaybookId) {
444
+ return {
445
+ reason: `the ${action} target may never be this Captain playbook itself`,
446
+ };
597
447
  }
598
- if (!event) {
599
- await this.emitStatus('classification was invalid; Boss input was not actionable.', { state });
600
- const result: PlaybookRunResult = { outcome: 'no-action', state };
601
- await this.traceSettled(result, currentTurnId);
602
- await this.drain();
603
- return result;
448
+ if (
449
+ !options.enabledPlaybooks.some((entry) => entry.id === playbookId)
450
+ ) {
451
+ return {
452
+ reason: `the ${action} target ${JSON.stringify(playbookId)} is not an enabled catalog id`,
453
+ };
604
454
  }
455
+ return {
456
+ selection: {
457
+ action,
458
+ playbookId,
459
+ input: parsed.input as string,
460
+ },
461
+ };
605
462
  }
606
- if (event?.type === 'NO_ACTION') {
607
- const result: PlaybookRunResult = { outcome: 'no-action', state };
608
- await this.traceSettled(result, currentTurnId);
609
- await this.drain();
610
- return result;
611
- }
612
- if (turn.signal.aborted) {
613
- const result: PlaybookRunResult = { outcome: 'aborted', state, error: normalizeError(turn.signal.reason) };
614
- await this.traceSettled(result, currentTurnId);
615
- await this.drain();
616
- return result;
463
+ case 'dismiss': {
464
+ const shape = requireKeys([]);
465
+ if (shape !== undefined) return { reason: shape };
466
+ return { selection: { action } };
617
467
  }
618
- if (actor.getSnapshot().status === 'done') {
619
- this.reconstructActor();
468
+ case 'deliver': {
469
+ // A deliver selection carries no text payload: the host is
470
+ // authoritative for the delivered text, so a carried `text` is
471
+ // ignored and never delivered (CAPPLAY-9).
472
+ const shape = requireKeys([], ['text']);
473
+ if (shape !== undefined) return { reason: shape };
474
+ return { selection: { action } };
620
475
  }
621
- this.requireActor().send(event);
622
- const snapshot = await waitForPlaybookQuiescence(this.requireActor(), { pendingCalls: nestedBridge });
623
- const settledState = normalizePlaybookSnapshot(snapshot, { pendingCall: nestedBridge.getPendingCall() });
624
- const result = turn.signal.aborted
625
- ? { outcome: 'aborted', state: settledState, error: normalizeError(turn.signal.reason) } satisfies PlaybookRunResult
626
- : resultFromState(
627
- settledState,
628
- this.machineOutput(),
629
- nestedBridge.getPendingCall(),
630
- this.latchedControlError,
631
- );
632
- await this.traceSettled(result, currentTurnId);
633
- await this.drain();
634
- return result;
635
- }
636
-
637
- private async resumePlaybookCallInner(input: { callId: string; result: PlaybookCallResult; signal: AbortSignal }): Promise<PlaybookRunResult> {
638
- const nestedBridge = this.requireBridge();
639
- this.activeBoundarySignal = input.signal;
640
- this.boundaryTurnId = this.playbookCallTurnIds.get(input.callId);
641
- let resumeError: unknown;
642
- try {
643
- await nestedBridge.resume(input);
644
- } catch (error) {
645
- resumeError = error;
476
+ case 'runtime': {
477
+ const shape = requireKeys(['actionId']) ?? nonEmpty('actionId');
478
+ if (shape !== undefined) return { reason: shape };
479
+ return { selection: { action, actionId: parsed.actionId as string } };
646
480
  }
647
- const snapshot = await waitForPlaybookQuiescence(this.requireActor(), { pendingCalls: nestedBridge });
648
- const pendingCall = nestedBridge.getPendingCall();
649
- const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
650
- const result = resultFromState(state, this.machineOutput(), pendingCall);
651
- await this.drain();
652
- if (resumeError !== undefined) throw resumeError;
653
- return input.signal.aborted ? { outcome: 'aborted', state, error: normalizeError(input.signal.reason) } : result;
481
+ default:
482
+ return { reason: `the reply names no known action (${action})` };
654
483
  }
484
+ }
655
485
 
656
- private async disposeInner(): Promise<void> {
657
- if (this.disposed) return;
658
- if (this.initializing) {
659
- await this.initializationDone;
660
- }
661
- const actor = this.actor;
662
- const bridge = this.nestedBridge;
663
- const finalState = actor ? stateFromSnapshot(actor, bridge?.getPendingCall()) : undefined;
664
- let cleanupError: unknown;
665
- this.suppressInspection = true;
666
- actor?.stop();
667
- try {
668
- await bridge?.dispose();
669
- } catch (error) {
670
- cleanupError = error;
671
- }
672
- if (this.initializing) {
673
- try {
674
- await this.drain();
675
- } catch (error) {
676
- if (cleanupError === undefined) cleanupError = error;
677
- }
678
- } else {
679
- try {
680
- await this.drain();
681
- } catch (error) {
682
- if (cleanupError === undefined) cleanupError = error;
683
- }
684
- }
685
- this.latchedControlError = undefined;
686
- if (finalState && !this.disposalTraceEmitted) {
687
- this.disposalTraceEmitted = true;
688
- try {
689
- await this.trace('session.disposed', omitUndefined({ state: finalState, stateId: finalState.stateId }));
690
- } catch (error) {
691
- if (cleanupError === undefined) cleanupError = error;
692
- }
693
- }
694
- try {
695
- await this.drain();
696
- } catch (error) {
697
- if (cleanupError === undefined) cleanupError = error;
698
- }
699
- this.session = undefined;
700
- this.actor = undefined;
701
- this.nestedBridge = undefined;
702
- this.disposed = true;
703
- if (cleanupError !== undefined) throw cleanupError;
704
- }
486
+ function selectionFromParsedDecision(
487
+ decision: ParsedActingDecision,
488
+ ): CaptainControllerSelection {
489
+ if (decision.action === 'deliver') {
490
+ return { action: 'deliver' };
491
+ }
492
+ // A parse-resolved `start` / `switch` is always the exact command remainder
493
+ // supplied by the host (CAPTAIN-7 command table).
494
+ return {
495
+ action: decision.action,
496
+ playbookId: decision.playbookId,
497
+ input: decision.input,
498
+ };
499
+ }
705
500
 
706
- private createActor(session: RuntimeSession, bridge: ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>>): RootActor {
707
- const provided = captainMachine.provide({
708
- actors: {
709
- captain: fromPromise<CaptainOutput, CaptainInput>(async ({ input, signal }) => {
710
- await this.drain();
711
- const combined = combineAbortSignals(signal, this.activeBoundarySignal);
712
- return await this.runCaptainActor(input, combined);
713
- }),
714
- playbook: bridge.actorLogic,
715
- },
716
- });
717
- let rootActor: RootActor;
718
- rootActor = createActor(provided, {
719
- input: {
720
- enabledPlaybooks: this.enabledPlaybooks,
721
- selfPlaybookId: session.playbookId,
722
- },
723
- inspect: (inspectionEvent) => {
724
- if (this.suppressInspection) return;
725
- if (inspectionEvent.type !== '@xstate.snapshot') return;
726
- if (inspectionEvent.actorRef !== rootActor) return;
727
- try {
728
- this.enqueueTransition(inspectionEvent.event, rootActor);
729
- } catch (error) {
730
- this.latchControlError(error);
731
- }
732
- },
733
- });
734
- return rootActor;
501
+ /** The machine retains the selected standalone input and settlement evidence. */
502
+ function decisionOutputOf(
503
+ selection: CaptainControllerSelection,
504
+ ): Record<string, unknown> {
505
+ if (selection.action === 'start' || selection.action === 'switch') {
506
+ return {
507
+ guard: selection.action,
508
+ playbookId: selection.playbookId,
509
+ input: selection.input,
510
+ };
735
511
  }
512
+ return { ...selection, guard: selection.action };
513
+ }
736
514
 
737
- private createBridge(session: RuntimeSession): ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>> {
738
- return createNestedPlaybookBridge<PlaybookInput>({
739
- nextCallId: () => `call-${this.nextCallId()}`,
740
- getBoundarySignal: () => this.activeBoundarySignal,
741
- callPlaybook: (request, signal) => session.ports.callPlaybook(request, signal),
742
- emitStarted: async (event) => {
743
- const turnId = this.currentTraceTurnId();
744
- if (turnId !== undefined) this.playbookCallTurnIds.set(event.callId, turnId);
745
- await this.trace('playbook.call.started', {
746
- stateId: event.stateId,
747
- playbookId: event.playbookId,
748
- text: event.text,
749
- }, turnId, event.callId);
750
- },
751
- emitFinished: async (event) => {
752
- const turnId = this.playbookCallTurnIds.get(event.callId) ?? this.currentTraceTurnId();
753
- await this.trace('playbook.call.finished', {
754
- stateId: event.stateId,
755
- playbookId: event.playbookId,
756
- text: event.text,
757
- result: event.result,
758
- }, turnId, event.callId);
759
- this.playbookCallTurnIds.delete(event.callId);
760
- },
761
- drain: () => this.drain(),
762
- bindResumeSignal: (signal) => {
763
- this.activeBoundarySignal = signal;
764
- },
765
- onControlPlaneError: (error) => this.latchControlError(error),
766
- onBackgroundError: (error) => this.latchControlError(error),
767
- });
515
+ // ---------------------------------------------------------------------------
516
+ // Settlement validation: the returned settlement is the only evidence of
517
+ // effects, and the machine retains only its status, facts, receipt
518
+ // disposition, and leaf-state summary (CAPPLAY-10). A malformed settlement
519
+ // is a host control-plane failure.
520
+ // ---------------------------------------------------------------------------
521
+
522
+ function validateSettlement(value: unknown): SettlementEvidence {
523
+ if (!isRecord(value)) {
524
+ throw new TypeError('controller settlement must be an object');
525
+ }
526
+ const allowed = new Set([
527
+ 'status',
528
+ 'facts',
529
+ 'reason',
530
+ 'receipt',
531
+ 'leafStateSummary',
532
+ ]);
533
+ for (const key of Object.keys(value)) {
534
+ if (!allowed.has(key)) {
535
+ throw new TypeError(`controller settlement carries undeclared ${key}`);
536
+ }
537
+ }
538
+ if (
539
+ value.status !== 'ok' &&
540
+ value.status !== 'rejected' &&
541
+ value.status !== 'failed'
542
+ ) {
543
+ throw new TypeError(
544
+ `controller settlement status must be ok | rejected | failed (got ${String(value.status)})`,
545
+ );
768
546
  }
769
-
770
- private async runCaptainActor(input: CaptainInput, signal: AbortSignal): Promise<CaptainOutput> {
771
- try {
772
- const prompt = composeCaptainPrompt(input);
773
- const result = await this.callCaptain(input, prompt, signal);
774
- if (signal.aborted) throw signal.reason;
775
- if (result.status !== 'ok') {
776
- throw new Error(result.error ?? `Captain returned ${result.status}`);
777
- }
778
- if (!result.finalText) {
779
- throw new Error('Captain returned ok without finalText');
780
- }
781
- const judgePrompt = makeJudgePrompt(input, result.finalText);
782
- const judgeText = await this.callJudge('captain-output-adjudication', judgePrompt, signal, input.stateId);
783
- return adjudicateCaptainOutput(input, result.finalText, judgeText);
784
- } catch (error) {
785
- if (!signal.aborted) this.latchControlError(error);
786
- throw error;
787
- }
547
+ if (
548
+ !Array.isArray(value.facts) ||
549
+ value.facts.some((fact: unknown) => typeof fact !== 'string')
550
+ ) {
551
+ throw new TypeError('controller settlement facts must be a string array');
552
+ }
553
+ if ('reason' in value && typeof value.reason !== 'string') {
554
+ throw new TypeError('controller settlement reason must be a string');
555
+ }
556
+ if (
557
+ 'leafStateSummary' in value &&
558
+ typeof value.leafStateSummary !== 'string'
559
+ ) {
560
+ throw new TypeError(
561
+ 'controller settlement leafStateSummary must be a string',
562
+ );
788
563
  }
789
-
790
- private async callCaptain(input: CaptainInput, prompt: string, signal: AbortSignal): Promise<CaptainResult> {
791
- const callId = `captain-${this.nextCallId()}`;
792
- const startPayload = {
793
- stateId: input.stateId,
794
- sourceItem: input.sourceItem,
795
- prompt,
796
- visibility: 'visible',
797
- resume: false,
798
- allowedTools: [],
799
- };
800
- try {
801
- await this.trace('captain.call.started', startPayload, this.currentTraceTurnId(), callId);
802
- } catch (error) {
803
- await this.tracePreservingError(
804
- 'captain.call.finished',
805
- {
806
- ...startPayload,
807
- status: 'error',
808
- error: normalizeError(error),
809
- },
810
- error,
811
- this.currentTraceTurnId(),
812
- callId,
813
- );
814
- throw error;
815
- }
816
- let result: CaptainResult | undefined;
817
- let failure: unknown;
818
- try {
819
- result = await this.captainLane.add(async () => {
820
- if (signal.aborted) throw signal.reason;
821
- const raw = await this.requireSession().ports.callCaptain(prompt, signal, CAPTAIN_OPTIONS);
822
- if (signal.aborted) throw signal.reason;
823
- return validateCaptainResult(raw);
824
- });
825
- if (result.status !== 'ok') {
826
- failure = new Error(result.error ?? `Captain returned ${result.status}`);
827
- } else if (!result.finalText) {
828
- failure = new Error('Captain returned ok without finalText');
829
- }
830
- } catch (error) {
831
- failure = error;
564
+ let receipt: SettlementReceiptEvidence | undefined;
565
+ if ('receipt' in value) {
566
+ if (!isRecord(value.receipt)) {
567
+ throw new TypeError('controller settlement receipt must be an object');
832
568
  }
833
- const normalized = failure === undefined ? undefined : normalizeError(failure);
834
- const abortedFailure = failure !== undefined && isSignalAbort(failure, signal);
835
- const finishPayload = {
836
- stateId: input.stateId,
837
- sourceItem: input.sourceItem,
838
- prompt,
839
- visibility: 'visible',
840
- resume: false,
841
- allowedTools: [],
842
- status: result?.status ?? (abortedFailure ? 'aborted' : 'error'),
843
- ...(result?.finalText === undefined ? {} : { finalText: result.finalText }),
844
- ...(result?.error === undefined ? {} : { error: result.error }),
845
- ...(normalized === undefined ? {} : { error: normalized }),
846
- };
847
- if (failure !== undefined) {
848
- if (isSignalAbort(failure, signal)) {
849
- await this.trace('captain.call.finished', finishPayload, this.currentTraceTurnId(), callId);
850
- throw failure;
569
+ const receiptAllowed = new Set(['disposition', 'reason', 'error']);
570
+ for (const key of Object.keys(value.receipt)) {
571
+ if (!receiptAllowed.has(key)) {
572
+ throw new TypeError(
573
+ `controller settlement receipt carries undeclared ${key}`,
574
+ );
851
575
  }
852
- await this.tracePreservingError('captain.call.finished', finishPayload, failure, this.currentTraceTurnId(), callId);
853
- throw failure;
854
576
  }
855
- await this.trace('captain.call.finished', finishPayload, this.currentTraceTurnId(), callId);
856
- if (failure !== undefined) throw failure;
857
- if (!result) throw new Error('Captain returned no result');
858
- return result;
859
- }
860
-
861
- private async callJudge(purpose: string, prompt: string, signal: AbortSignal, stateId?: string): Promise<string> {
862
- const callId = `judge-${this.nextCallId()}`;
863
- const startPayload = omitUndefined({ purpose, prompt, stateId });
864
- try {
865
- await this.trace('judge.call.started', startPayload, this.currentTraceTurnId(), callId);
866
- } catch (error) {
867
- await this.tracePreservingError(
868
- 'judge.call.finished',
869
- omitUndefined({ purpose, prompt, stateId, status: 'error', error: normalizeError(error) }),
870
- error,
871
- this.currentTraceTurnId(),
872
- callId,
577
+ const disposition = value.receipt.disposition;
578
+ if (
579
+ disposition !== 'executed' &&
580
+ disposition !== 'rejected' &&
581
+ disposition !== 'failed'
582
+ ) {
583
+ throw new TypeError(
584
+ 'controller settlement receipt disposition must be executed | rejected | failed',
873
585
  );
874
- throw error;
875
586
  }
876
- let reply: string | undefined;
877
- let failure: unknown;
878
- try {
879
- reply = await this.captainLane.add(async () => {
880
- if (signal.aborted) throw signal.reason;
881
- const text = await this.requireSession().ports.callJudge(prompt, signal);
882
- if (signal.aborted) throw signal.reason;
883
- if (typeof text !== 'string') throw new TypeError('judge reply must be a string');
884
- return text;
885
- });
886
- } catch (error) {
887
- failure = error;
587
+ if ('reason' in value.receipt && typeof value.receipt.reason !== 'string') {
588
+ throw new TypeError(
589
+ 'controller settlement receipt reason must be a string',
590
+ );
888
591
  }
889
- if (failure !== undefined) {
890
- const aborted = isSignalAbort(failure, signal);
891
- const finishPayload = omitUndefined({
892
- purpose,
893
- prompt,
894
- stateId,
895
- status: aborted ? 'aborted' : 'error',
896
- error: normalizeError(failure),
897
- });
898
- if (aborted) {
899
- await this.trace('judge.call.finished', finishPayload, this.currentTraceTurnId(), callId);
900
- } else {
901
- await this.tracePreservingError(
902
- 'judge.call.finished',
903
- finishPayload,
904
- failure,
905
- this.currentTraceTurnId(),
906
- callId,
592
+ let error: { name: string; message: string } | undefined;
593
+ if ('error' in value.receipt) {
594
+ const compact = normalizeErrorCompact(value.receipt.error);
595
+ if (compact === undefined) {
596
+ throw new TypeError(
597
+ 'controller settlement receipt error must be a normalized error',
907
598
  );
908
599
  }
909
- throw failure;
910
- }
911
- await this.trace(
912
- 'judge.call.finished',
913
- omitUndefined({ purpose, prompt, stateId, status: 'ok', reply }),
914
- this.currentTraceTurnId(),
915
- callId,
916
- );
917
- if (reply === undefined) throw new Error('judge returned no reply');
918
- return reply;
919
- }
920
-
921
- private async classifyBossInput(text: string, state: PlaybookState, signal: AbortSignal): Promise<BossMapping> {
922
- const pending = this.pendingQuestion();
923
- const prompt = classifierPrompt(text, state, pending ? { questionId: pending.questionId, player: pending.player, question: pending.question } : undefined);
924
- const reply = await this.callJudge('boss-input-classification', prompt, signal, state.stateId);
925
- const event = validateClassifier(reply, text, pending?.questionId);
926
- if (!event) return undefined;
927
- return event;
928
- }
929
-
930
- private pendingQuestion(): { readonly questionId: string; readonly player: string; readonly question: string } | undefined {
931
- const snapshot = this.actor?.getSnapshot();
932
- const context = snapshot?.context as unknown;
933
- if (!isRecord(context) || !isRecord(context.pendingBossQuestion)) return undefined;
934
- return {
935
- questionId: assertNonEmptyString(context.pendingBossQuestion.questionId, 'pending question id'),
936
- player: assertNonEmptyString(context.pendingBossQuestion.player, 'pending question player'),
937
- question: assertNonEmptyString(context.pendingBossQuestion.question, 'pending question text'),
938
- };
939
- }
940
-
941
- private enqueueTransition(event: unknown, actor: RootActor): void {
942
- const state = stateFromSnapshot(actor, this.nestedBridge?.getPendingCall());
943
- const previousState = this.previousState ?? state;
944
- this.previousState = state;
945
- const transition = omitUndefined({
946
- event: this.describeEvent(event),
947
- from: previousState,
948
- to: state,
949
- previousState,
950
- state,
951
- stateId: state.stateId,
952
- pendingBossQuestion: this.pendingQuestion(),
953
- lastError: this.lastError(),
954
- });
955
- this.enqueue(async () => {
956
- await this.traceNow('fsm.transition', transition, this.currentTraceTurnId());
957
- await this.requireSession().ports.emitTelemetry({ topic: 'playbook.fsm.state', payload: transition });
958
- if (state.stateId !== 'ready' && state.stateId !== 'done') {
959
- await this.traceNow('status.emitted', omitUndefined({ message: `Entered ${state.stateId ?? 'state'}`, state, stateId: state.stateId }), this.currentTraceTurnId());
960
- await this.requireSession().ports.emitStatus(`Entered ${state.stateId ?? 'state'}`, transition);
961
- }
600
+ error = compact;
601
+ }
602
+ receipt = Object.freeze({
603
+ disposition,
604
+ ...(typeof value.receipt.reason === 'string'
605
+ ? { reason: value.receipt.reason }
606
+ : {}),
607
+ ...(error === undefined ? {} : { error: Object.freeze(error) }),
962
608
  });
963
609
  }
610
+ return Object.freeze({
611
+ status: value.status,
612
+ facts: Object.freeze([...(value.facts as readonly string[])]),
613
+ ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
614
+ ...(receipt === undefined ? {} : { receipt }),
615
+ ...(typeof value.leafStateSummary === 'string'
616
+ ? { leafStateSummary: value.leafStateSummary }
617
+ : {}),
618
+ });
619
+ }
964
620
 
965
- private describeEvent(event: unknown): JsonValue {
966
- if (!isRecord(event)) return { type: 'unknown' };
967
- const type = typeof event.type === 'string' ? event.type : 'unknown';
968
- const copy: Record<string, JsonValue> = { type };
969
- for (const key of ['bossIntent', 'targetId', 'answer', 'questionId', 'output']) {
970
- if (key in event && event[key] === undefined) continue;
971
- if (key in event) copy[key] = snapshotJsonValue(event[key], `event.${key}`);
972
- }
973
- if ('error' in event) copy.error = snapshotJsonValue(normalizeError(event.error));
974
- return snapshotJsonValue(copy);
975
- }
976
-
977
- private lastError(): JsonValue | undefined {
978
- const context = this.actor?.getSnapshot().context as unknown;
979
- if (!isRecord(context) || !('lastError' in context)) return undefined;
980
- if (context.lastError === undefined) return undefined;
981
- return snapshotJsonValue(context.lastError, 'lastError');
982
- }
983
-
984
- private machineOutput(): JsonValue | undefined {
985
- const snapshot = this.actor?.getSnapshot();
986
- if (!snapshot || snapshot.status !== 'done') return undefined;
987
- const output = snapshot.output as unknown;
988
- return output === undefined ? undefined : snapshotJsonValue(output, 'machine output');
989
- }
990
-
991
- private async emitStatus(message: string, data?: unknown): Promise<void> {
992
- const state = stateFromSnapshot(this.requireActor(), this.nestedBridge?.getPendingCall());
993
- const payload = omitUndefined({ message, data, state, stateId: state.stateId });
994
- await this.trace('status.emitted', payload, this.currentTraceTurnId());
995
- await this.requireSession().ports.emitStatus(message, data);
996
- }
997
-
998
- private async traceSettled(result: PlaybookRunResult, turnId: number): Promise<void> {
999
- await this.trace('boss.input.settled', this.runResultPayload(result), turnId);
1000
- }
1001
-
1002
- private runResultPayload(result: PlaybookRunResult): JsonValue {
1003
- return omitUndefined({
1004
- outcome: result.outcome,
1005
- state: result.state,
1006
- stateId: result.state.stateId,
1007
- pendingCall: 'pendingCall' in result ? result.pendingCall : undefined,
1008
- output: 'output' in result ? result.output : undefined,
1009
- error: 'error' in result ? result.error : undefined,
1010
- });
1011
- }
1012
-
1013
- private async trace(type: PlaybookTraceEvent['type'], payload: unknown, turnId?: number, callId?: string): Promise<void> {
1014
- this.enqueue(async () => {
1015
- await this.traceNow(type, payload, turnId, callId);
1016
- });
1017
- await this.drain();
621
+ // ---------------------------------------------------------------------------
622
+ // The controller captain-call strategy (slc/link.md §Captain adjudication,
623
+ // controller form). The engine composes the prompt, traces every call as its
624
+ // own captain.call pair on the shared serialized lane, and owns
625
+ // control-plane latching; this strategy owns the hidden calls, the decision
626
+ // validation with its one corrective re-ask, and the controller-port
627
+ // submission.
628
+ // ---------------------------------------------------------------------------
629
+
630
+ const HIDDEN = { visibility: 'hidden' } as const;
631
+
632
+ async function callHidden(
633
+ run: XStateCaptainStrategyRun<ValidatedCaptainOptions>,
634
+ prompt: string,
635
+ ): Promise<CaptainResult> {
636
+ try {
637
+ return await run.callCaptain(prompt, HIDDEN);
638
+ } catch (error) {
639
+ // DR-028: exactly one corrective re-ask of the same composed call on an
640
+ // empty `ok` result; a second such result follows the failure path from
641
+ // the boundary itself.
642
+ if (!run.isEmptyOkRetry(error)) throw error;
643
+ return await run.callCaptain(prompt, HIDDEN);
1018
644
  }
645
+ }
1019
646
 
1020
- private async tracePreservingError(
1021
- type: PlaybookTraceEvent['type'],
1022
- payload: unknown,
1023
- preservedError: unknown,
1024
- turnId?: number,
1025
- callId?: string,
1026
- ): Promise<void> {
1027
- const previous = this.latchedControlError;
1028
- this.latchedControlError = undefined;
1029
- try {
1030
- await this.trace(type, payload, turnId, callId);
1031
- } catch (error) {
1032
- // Preserve the earlier boundary/control failure.
1033
- } finally {
1034
- this.latchedControlError = previous ?? preservedError;
1035
- }
647
+ function requireControllerPort(
648
+ options: ValidatedCaptainOptions,
649
+ ): CaptainControllerPort {
650
+ if (options.controller === undefined) {
651
+ throw new Error(
652
+ 'captain.playbook: the host-supplied controller port is required to settle a Boss turn (CAPPLAY-9); construct the runtime with options.controller',
653
+ );
1036
654
  }
655
+ return options.controller;
656
+ }
1037
657
 
1038
- private async traceNow(type: PlaybookTraceEvent['type'], payload: unknown, turnId?: number, callId?: string): Promise<void> {
1039
- const session = this.requireSession();
1040
- const event: PlaybookTraceEvent = {
1041
- schemaVersion: 2,
1042
- sessionId: session.sessionId,
1043
- playbookId: session.playbookId,
1044
- rootSessionId: session.rootSessionId,
1045
- ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }),
1046
- ...(session.parentCallId === undefined ? {} : { parentCallId: session.parentCallId }),
1047
- depth: session.depth,
1048
- sequence: this.nextSequence(),
1049
- timestamp: Date.now(),
1050
- type,
1051
- ...(turnId === undefined ? {} : { turnId }),
1052
- ...(callId === undefined ? {} : { callId }),
1053
- payload: snapshotJsonValue(payload, `trace ${type}`),
1054
- };
1055
- await session.ports.emitTelemetry({ topic: 'playbook.trace', payload: event });
1056
- }
658
+ async function submitSelection(
659
+ run: XStateCaptainStrategyRun<ValidatedCaptainOptions>,
660
+ selection: CaptainControllerSelection,
661
+ ): Promise<SettlementEvidence> {
662
+ const port = requireControllerPort(run.options);
663
+ const settlement = validateSettlement(
664
+ await port.submit(selection, run.signal),
665
+ );
666
+ run.signal.throwIfAborted();
667
+ return settlement;
668
+ }
1057
669
 
1058
- private enqueue(task: () => Promise<void>): void {
1059
- void this.emissionQueue.add(async () => {
1060
- try {
1061
- await task();
1062
- } catch (error) {
1063
- this.latchControlError(error);
1064
- throw error;
670
+ async function runDecisionState(
671
+ run: XStateCaptainStrategyRun<ValidatedCaptainOptions>,
672
+ ): Promise<CaptainOutput> {
673
+ const input = run.input as unknown as CaptainInput;
674
+ const declaredActions = new Set(Object.keys(run.input.result));
675
+ let selection: CaptainControllerSelection;
676
+ if (input.parsedDecision !== undefined) {
677
+ // A parse-resolved turn enters with its decision already made: the
678
+ // injected decision object is the turn's decision and no decision call
679
+ // occurs (CAPTAIN-7, CAPPLAY-6). A malformed injection is a host bug —
680
+ // control-plane, not a corrective re-ask.
681
+ selection = selectionFromParsedDecision(
682
+ validateParsedActingDecision(input.parsedDecision),
683
+ );
684
+ } else {
685
+ const first = await callHidden(run, run.prompt);
686
+ const firstReading = readDecisionReply(
687
+ first.finalText ?? '',
688
+ run.options,
689
+ run.session.playbookId,
690
+ declaredActions,
691
+ );
692
+ if (firstReading.selection !== undefined) {
693
+ selection = firstReading.selection;
694
+ } else {
695
+ // CAPPLAY-18: exactly one corrective call appending the rejection
696
+ // reason and the restated reply contract to the same prompt.
697
+ const second = await callHidden(
698
+ run,
699
+ correctiveDecisionPrompt(run.prompt, firstReading.reason),
700
+ );
701
+ const secondReading = readDecisionReply(
702
+ second.finalText ?? '',
703
+ run.options,
704
+ run.session.playbookId,
705
+ declaredActions,
706
+ );
707
+ if (secondReading.selection === undefined) {
708
+ // A second malformed reply settles the turn as a Boss-appropriate
709
+ // failure reply with no action executed and the engagement stack
710
+ // untouched; the machine returns to its hub (CAPPLAY-18/19). The
711
+ // marker property routes the FSM's authored hub-recovery arm.
712
+ const failure = new Error(
713
+ `the decision reply stayed malformed after one corrective re-ask: ${secondReading.reason}`,
714
+ ) as Error & { controllerDecisionFailure?: boolean };
715
+ failure.name = 'ControllerDecisionError';
716
+ failure.controllerDecisionFailure = true;
717
+ throw run.recoverableFailure(failure);
1065
718
  }
1066
- }).catch(() => undefined);
1067
- }
1068
-
1069
- private drain(): Promise<void> {
1070
- return this.emissionQueue.onIdle().then(() => {
1071
- if (this.latchedControlError) throw this.latchedControlError;
1072
- });
1073
- }
1074
-
1075
- private async bestEffortDisposeTrace(state: PlaybookState): Promise<void> {
1076
- try {
1077
- this.disposalTraceEmitted = true;
1078
- await this.trace('session.disposed', omitUndefined({ state, stateId: state.stateId }));
1079
- await this.drain();
1080
- } catch {
1081
- // Preserve the original initialization error.
719
+ selection = secondReading.selection;
1082
720
  }
1083
721
  }
722
+ const settlement = await submitSelection(run, selection);
723
+ return {
724
+ ...decisionOutputOf(selection),
725
+ settlement,
726
+ } as unknown as CaptainOutput;
727
+ }
1084
728
 
1085
- private reconstructActor(): void {
1086
- this.actor?.stop();
1087
- const session = this.requireSession();
1088
- const bridge = this.requireBridge();
1089
- this.actor = this.createActor(session, bridge);
1090
- this.actor.start();
1091
- }
1092
-
1093
- private requireSession(): RuntimeSession {
1094
- if (!this.session) throw new Error('playbook runtime is not initialized');
1095
- return this.session;
1096
- }
1097
-
1098
- private requireActor(): RootActor {
1099
- if (!this.actor) throw new Error('playbook runtime actor is not initialized');
1100
- return this.actor;
1101
- }
1102
-
1103
- private requireBridge(): ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>> {
1104
- if (!this.nestedBridge) throw new Error('nested bridge is not initialized');
1105
- return this.nestedBridge;
1106
- }
1107
-
1108
- private nextSequence(): number {
1109
- this.sequence += 1;
1110
- return this.sequence;
1111
- }
1112
-
1113
- private nextTurnId(): number {
1114
- this.turnId += 1;
1115
- return this.turnId;
1116
- }
1117
-
1118
- private currentTraceTurnId(): number | undefined {
1119
- return this.boundaryTurnId;
1120
- }
729
+ async function controllerCaptainStrategy(
730
+ run: XStateCaptainStrategyRun<ValidatedCaptainOptions>,
731
+ ): Promise<PlaybookActorOutput> {
732
+ if (run.input.stateId === 'deciding') {
733
+ return (await runDecisionState(run)) as unknown as PlaybookActorOutput;
734
+ }
735
+ // The prose states (answeringCommand, reporting): one hidden durable call
736
+ // whose validated text the host surfaces as captain speech through its
737
+ // presentation seam; the machine retains no reply prose (CAPPLAY-10) and
738
+ // the state settles with the default single-outcome `done` contract.
739
+ await callHidden(run, run.prompt);
740
+ return { guard: 'done' };
741
+ }
1121
742
 
1122
- private nextCallId(): number {
1123
- this.callId += 1;
1124
- return this.callId;
1125
- }
743
+ // ---------------------------------------------------------------------------
744
+ // Captain-pane status lines. The shell suppresses this runtime's human
745
+ // status stream while forwarding structured telemetry (CAPPLAY-9); the
746
+ // lines stay Boss-appropriate regardless. The parked hub and the shutdown
747
+ // state emit no human status (slc/link.md §Session lifecycle).
748
+ // ---------------------------------------------------------------------------
1126
749
 
1127
- private latchControlError(error: unknown): void {
1128
- if (!this.latchedControlError) this.latchedControlError = error;
750
+ function statusesForState(
751
+ state: PlaybookState,
752
+ context: Record<string, unknown>,
753
+ ): ScheduledStatus[] {
754
+ switch (state.stateId) {
755
+ case 'deciding':
756
+ return [{ message: '⤷ Captain: decide the Boss turn' }];
757
+ case 'answeringCommand':
758
+ return [{ message: '⤷ Captain: answer the command turn' }];
759
+ case 'reporting':
760
+ return [{ message: '⤷ Captain: compose the closing reply' }];
761
+ case 'failed': {
762
+ const lastError = normalizeErrorCompact(context.lastError);
763
+ return [
764
+ {
765
+ message: '◆ failed',
766
+ ...(lastError === undefined
767
+ ? {}
768
+ : { data: snapshotJsonValue({ lastError }, 'failed status data') }),
769
+ },
770
+ ];
771
+ }
772
+ default:
773
+ return [];
1129
774
  }
1130
775
  }
1131
776
 
777
+ // Internal export surface for verification and tests. Not part of the
778
+ // stable public API; the leading underscore signals "subject to change."
1132
779
  export const _internal = {
1133
- composeCaptainPrompt,
1134
- composePlayerPrompt,
1135
- parseJsonObjectLoose,
780
+ // No placeholder exists in any compiled Captain prompt: the labeled
781
+ // Boss-message, ControlView digest, and catalog digest blocks are
782
+ // shell-composed inside the hidden-control envelope (CAPTAIN-9), so the
783
+ // shared default composer emits the verbatim GEARS domain body.
784
+ composeCaptainPrompt: (input: CaptainInput): string =>
785
+ defaultComposeCaptainPrompt(input as unknown as PlaybookCaptainInput),
786
+ classifyControllerTurn,
787
+ readDecisionReply,
788
+ correctiveDecisionPrompt,
789
+ validateSettlement,
790
+ validateParsedActingDecision,
791
+ statusesForState,
792
+ normalizeError,
1136
793
  };
1137
794
 
1138
- export function createPlaybookRuntime(options: PlaybookRuntimeOptions): PlaybookRuntime {
1139
- return new CaptainPlaybookRuntime(options);
795
+ // The Captain-specific spec handed to the shared runtime factory
796
+ // (slc/link.md §Output, DR-019). Generic machinery — actor wiring, boundary
797
+ // tracing, lifecycle, abort, the parked-session snapshot, and the DR-029
798
+ // describe/apply control surface — lives in @sublang/playbook/xstate-runtime.
799
+ const runtimeSpec: XStatePlaybookRuntimeSpec<ValidatedCaptainOptions> = {
800
+ label: 'CAPTAIN',
801
+ compat: { artifactSchema: 1, runtimeAbi: RUNTIME_ABI },
802
+ snapshotOptions: snapshotCaptainOptions,
803
+ machineInput: (options) => ({ enabledPlaybooks: options.enabledPlaybooks }),
804
+ classifyBossText: (text, ports, signal, snapshotOrState, boundary, options) =>
805
+ classifyControllerTurn(
806
+ text,
807
+ ports,
808
+ signal,
809
+ snapshotOrState,
810
+ boundary,
811
+ options,
812
+ ) as Promise<import('xstate').EventObject | undefined>,
813
+ captainStrategy: controllerCaptainStrategy,
814
+ // CAPPLAY-10 / PBRT-52: the Captain's own ControlView context projection —
815
+ // the settlement evidence the machine retains for its decision and reply
816
+ // phases, and nothing else. `enabledPlaybooks` is the host-supplied
817
+ // catalog (an option value), and `parsedDecision` and `lastError` are
818
+ // control-plane scratch; none of the three is settlement evidence, so none
819
+ // is exported. Declaring the list here is what makes "Captain-visible
820
+ // context" an enumerated set rather than whatever the FSM happens to hold.
821
+ controlContextFields: [
822
+ 'bossText',
823
+ 'selectedAction',
824
+ 'settlementStatus',
825
+ 'settlementFacts',
826
+ 'settlementReason',
827
+ 'receiptDisposition',
828
+ 'receiptReason',
829
+ 'receiptError',
830
+ 'leafStateSummary',
831
+ ],
832
+ statusesForState,
833
+ };
834
+
835
+ const createCaptainPlaybookRuntime: PlaybookRuntimeFactory<ValidatedCaptainOptions> =
836
+ createXStatePlaybookRuntime(captainMachine, runtimeSpec);
837
+
838
+ export function createPlaybookRuntime(
839
+ options: PlaybookRuntimeOptions,
840
+ ): PlaybookRuntime {
841
+ return createCaptainPlaybookRuntime(options);
1140
842
  }
1141
843
 
1142
- const factory: PlaybookRuntimeFactory<PlaybookRuntimeOptions> = createPlaybookRuntime;
844
+ const factory: PlaybookRuntimeFactory<PlaybookRuntimeOptions> =
845
+ createPlaybookRuntime;
1143
846
 
1144
847
  export default factory;