@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.
- package/README.md +64 -99
- package/docs/assets/playbook-venn.svg +13 -0
- package/docs/cli.md +83 -9
- package/docs/configuration.md +5 -3
- package/package.json +7 -4
- package/reference/sdlc/captain.md +70 -83
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
- package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
- package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
- package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
- package/reference/sdlc/captain.playbook/captain.playbook.js +464 -968
- package/reference/sdlc/captain.playbook/captain.playbook.ts +696 -993
- package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +54 -9
- package/reference/sdlc/code.playbook/bin/run.js +97 -0
- package/reference/sdlc/code.playbook/code.playbook.js +17 -0
- package/reference/sdlc/code.playbook/code.playbook.ts +17 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +1784 -215
- package/reference/sdlc/code.playbook/playbook-captain.ts +2293 -330
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +41 -9
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +42 -9
- package/slc/gears2fsm.md +54 -2
- package/slc/link.md +293 -25
- package/src/runtime.d.ts +29 -1
- package/src/runtime.ts +47 -0
- package/src/xstate-playbook-runtime.d.ts +97 -5
- package/src/xstate-playbook-runtime.js +769 -29
- 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
|
|
5
|
-
// FSM
|
|
6
|
-
// Player
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
} from '../../../src/runtime.js';
|
|
110
|
+
PlaybookTraceType,
|
|
111
|
+
};
|
|
66
112
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
124
|
+
/** One nonempty complete standalone request selected for `start` or `switch`. */
|
|
125
|
+
export type CaptainControllerInput = string;
|
|
72
126
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
82
|
-
readonly
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
readonly
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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(
|
|
204
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return {
|
|
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 (
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
if (
|
|
333
|
-
throw new
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
return { type: '
|
|
358
|
-
}
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
`Your previous control reply was rejected: ${reason}.`,
|
|
376
|
+
RESTATED_REPLY_CONTRACT,
|
|
388
377
|
].join('\n');
|
|
389
378
|
}
|
|
390
379
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
):
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
856
|
-
if (
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
return
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if (!
|
|
987
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
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> =
|
|
844
|
+
const factory: PlaybookRuntimeFactory<PlaybookRuntimeOptions> =
|
|
845
|
+
createPlaybookRuntime;
|
|
1143
846
|
|
|
1144
847
|
export default factory;
|