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