@sublang/playbook 0.9.0 → 1.3.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 (51) hide show
  1. package/README.md +190 -151
  2. package/package.json +50 -6
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
  12. package/reference/sdlc/code.playbook/bin/run.js +999 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
  21. package/reference/sdlc/code.playbook/code.playbook.js +199 -488
  22. package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
  24. package/reference/sdlc/code.playbook/code.registry.js +0 -3
  25. package/reference/sdlc/code.playbook/code.registry.ts +0 -6
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
  27. package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1165 -89
  42. package/slc/optimize.md +92 -0
  43. package/slc/text2gears.md +255 -7
  44. package/src/runtime.d.ts +146 -3
  45. package/src/runtime.ts +201 -2
  46. package/src/xstate-playbook-runtime.d.ts +201 -0
  47. package/src/xstate-playbook-runtime.js +2058 -0
  48. package/src/xstate-playbook-runtime.ts +2792 -0
  49. package/src/xstate-runtime.d.ts +95 -0
  50. package/src/xstate-runtime.js +1258 -0
  51. package/src/xstate-runtime.ts +1816 -0
@@ -0,0 +1,851 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import { assign, fromPromise, setup } from 'xstate';
5
+
6
+ export type JsonValue =
7
+ | null
8
+ | boolean
9
+ | number
10
+ | string
11
+ | readonly JsonValue[]
12
+ | { readonly [key: string]: JsonValue };
13
+
14
+ export type EnabledPlaybook = {
15
+ readonly id: string;
16
+ readonly command: string;
17
+ readonly intent: string;
18
+ };
19
+
20
+ export type NormalizedError = {
21
+ readonly name: string;
22
+ readonly message: string;
23
+ readonly stack?: string;
24
+ };
25
+
26
+ export type PlaybookStateValue =
27
+ | string
28
+ | { readonly [key: string]: PlaybookStateValue };
29
+
30
+ export type PlaybookState = {
31
+ readonly value: PlaybookStateValue;
32
+ readonly activeStateIds: readonly string[];
33
+ readonly tags: readonly string[];
34
+ readonly status: 'active' | 'done' | 'error' | 'stopped';
35
+ readonly quiescent: boolean;
36
+ readonly stateId?: string;
37
+ };
38
+
39
+ export type CompletedCallResult =
40
+ | {
41
+ readonly playbookId: string;
42
+ readonly status: 'ok';
43
+ readonly output?: JsonValue;
44
+ }
45
+ | {
46
+ readonly playbookId: string;
47
+ readonly status: 'aborted' | 'error';
48
+ readonly error: NormalizedError;
49
+ };
50
+
51
+ export type PendingBossQuestion = {
52
+ readonly questionId: ResumableStateId;
53
+ readonly resumeStateId: ResumableStateId;
54
+ readonly sourceItem: 'CAPTAIN-1' | 'CAPTAIN-3';
55
+ readonly player: 'Captain';
56
+ readonly question: string;
57
+ };
58
+
59
+ export type ResumableStateId = 'routing' | 'reassessing';
60
+
61
+ export type CaptainMachineInput = {
62
+ readonly enabledPlaybooks: readonly EnabledPlaybook[];
63
+ readonly selfPlaybookId: string;
64
+ readonly bossIntent?: string;
65
+ };
66
+
67
+ export type CaptainMachineOutput = {
68
+ readonly response: string;
69
+ };
70
+
71
+ export type CaptainInput = {
72
+ readonly stateId: ResumableStateId;
73
+ readonly sourceItem: 'CAPTAIN-1' | 'CAPTAIN-3';
74
+ readonly prompt: string;
75
+ readonly result: Record<string, string>;
76
+ readonly bossIntent: string;
77
+ readonly enabledPlaybooks: readonly EnabledPlaybook[];
78
+ readonly remainingPlan?: readonly JsonValue[];
79
+ readonly completedCallResults?: readonly CompletedCallResult[];
80
+ readonly pendingBossQuestion?: PendingBossQuestion;
81
+ readonly bossReply?: string;
82
+ };
83
+
84
+ export type CaptainOutput =
85
+ | {
86
+ readonly guard: 'question';
87
+ readonly question: string;
88
+ }
89
+ | {
90
+ readonly guard: 'delegation';
91
+ readonly remainingPlan: readonly JsonValue[];
92
+ readonly nextPlaybookId: string;
93
+ readonly nextPlaybookInput: string;
94
+ }
95
+ | {
96
+ readonly guard: 'final';
97
+ readonly response: string;
98
+ }
99
+ | {
100
+ readonly guard: 'followUpQuestion';
101
+ readonly question: string;
102
+ }
103
+ | {
104
+ readonly guard: 'continuing';
105
+ readonly remainingPlan: readonly JsonValue[];
106
+ readonly nextPlaybookId: string;
107
+ readonly nextPlaybookInput: string;
108
+ }
109
+ | {
110
+ readonly guard: 'needsBossReply';
111
+ readonly question: string;
112
+ };
113
+
114
+ export type PlaybookInput = {
115
+ readonly stateId: 'callPlaybook';
116
+ readonly sourceItem?: 'CAPTAIN-2';
117
+ readonly playbookId: string;
118
+ readonly text: string;
119
+ readonly playbookIdContext: 'nextPlaybookId';
120
+ readonly textContext: 'nextPlaybookInput';
121
+ };
122
+
123
+ export type PlaybookOutput = JsonValue | undefined;
124
+
125
+ type Context = {
126
+ readonly bossIntent: string;
127
+ readonly enabledPlaybooks: readonly EnabledPlaybook[];
128
+ readonly selfPlaybookId: string;
129
+ readonly remainingPlan: readonly JsonValue[];
130
+ readonly completedCallResults: readonly CompletedCallResult[];
131
+ readonly nextPlaybookId: string;
132
+ readonly nextPlaybookInput: string;
133
+ readonly callHistory: readonly string[];
134
+ readonly response?: string;
135
+ readonly pendingBossQuestion?: PendingBossQuestion;
136
+ readonly bossReply?: string;
137
+ readonly lastError?: JsonValue;
138
+ };
139
+
140
+ type BossIntentEvent = {
141
+ readonly type: 'BOSS_INTENT';
142
+ readonly bossIntent: string;
143
+ };
144
+
145
+ type BossInterruptEvent = {
146
+ readonly type: 'BOSS_INTERRUPT';
147
+ readonly targetId: 'routing';
148
+ readonly bossIntent: string;
149
+ };
150
+
151
+ type BossReplyEvent = {
152
+ readonly type: 'BOSS_REPLY';
153
+ readonly answer: string;
154
+ readonly questionId?: string;
155
+ };
156
+
157
+ type CaptainMachineEvent = BossIntentEvent | BossInterruptEvent | BossReplyEvent;
158
+
159
+ type DoneActorEvent = {
160
+ readonly output: unknown;
161
+ };
162
+
163
+ type ErrorActorEvent = {
164
+ readonly error: unknown;
165
+ };
166
+
167
+ const ROUTING_PROMPT = [
168
+ 'Boss intent: <boss-intent>',
169
+ 'Enabled playbooks: <enabled-playbooks>',
170
+ 'You are routing this intent, not performing the requested work.',
171
+ 'Use only the Boss intent and enabled-playbooks catalog supplied here.',
172
+ 'Do not investigate the task, inspect files or project state, use tools, or attempt the specialized work yourself.',
173
+ "Preserve Boss's intended outcome and constraints.",
174
+ 'If the supplied evidence identifies a useful route, select an enabled playbook; do not finish the intent yourself.',
175
+ 'Ask exactly one concise question only when its answer is necessary to choose a useful route or call order.',
176
+ 'For a complex intent, divide it into the smallest finite ordered plan of useful playbook calls.',
177
+ 'Name the selected first playbook and state its complete standalone request containing only the context it needs.',
178
+ 'List any later playbook calls in their intended order after the selected first call.',
179
+ 'Do not call a playbook merely to restate or classify the intent.',
180
+ 'Write only concise human-facing routing prose or the one routing question.',
181
+ 'Do not emit JSON, guard names, result property names, or control instructions.',
182
+ 'Do not expose internal state ids, session ids, call ids, stack data, hidden control data, or private reasoning.',
183
+ ].join('\n');
184
+
185
+ const REASSESS_PROMPT = [
186
+ 'Boss intent: <boss-intent>',
187
+ 'Enabled playbooks: <enabled-playbooks>',
188
+ 'Remaining plan: <remaining-plan>',
189
+ 'Completed call results: <completed-call-results>',
190
+ "Preserve Boss's intended outcome and constraints.",
191
+ 'Treat each returned result as evidence and revise the remaining plan when needed.',
192
+ 'A continuing decision must strictly reduce the remaining plan length.',
193
+ 'Do not repeat an equivalent failed or completed call without new information.',
194
+ 'If the intent is fulfilled, give Boss one concise final response that states the result or actionable conclusion.',
195
+ 'Do not finish with a bare acknowledgement, a promise to act, or an announcement that the round is complete.',
196
+ 'If information from Boss is now necessary, ask exactly one concise question.',
197
+ 'Otherwise name exactly one next enabled playbook and state its complete standalone request containing only the context it needs.',
198
+ 'List any still-later playbook calls in their intended order after the selected next call.',
199
+ 'Write only concise human-facing final, question, or routing prose.',
200
+ 'Do not emit JSON, guard names, result property names, or control instructions.',
201
+ 'Do not expose internal state ids, session ids, call ids, stack data, hidden control data, or private reasoning.',
202
+ ].join('\n');
203
+
204
+ const NEEDS_BOSS_REPLY_DESCRIPTION =
205
+ "The acting agent's prose surfaces a clarifying question for Boss that the agent cannot answer alone. Output shall include `question: <verbatim question text from the acting agent's prose>`.";
206
+
207
+ const ROUTING_RESULTS = {
208
+ question:
209
+ 'Captain asked the one material routing question. Output shall include `question: <verbatim final text from the visible Captain call>`.',
210
+ delegation:
211
+ 'Captain selected the first useful call. Output shall include `remainingPlan: <finite JSON-safe array of only later calls>`, `nextPlaybookId: <selected stable enabled-playbook id>`, and `nextPlaybookInput: <complete standalone request>`.',
212
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
213
+ } as const;
214
+
215
+ const REASSESS_RESULTS = {
216
+ final:
217
+ 'Captain gave Boss the concrete result or actionable conclusion. Output shall include `response: <verbatim final text from the visible Captain call>`.',
218
+ followUpQuestion:
219
+ 'Captain asked one necessary follow-up question. Output shall include `question: <verbatim final text from the visible Captain call>`.',
220
+ continuing:
221
+ 'Captain selected another useful call. Output shall include `remainingPlan: <strictly shorter finite JSON-safe array of only later calls>`, `nextPlaybookId: <selected stable enabled-playbook id>`, and `nextPlaybookInput: <complete standalone request>`.',
222
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
223
+ } as const;
224
+
225
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
226
+ if (value === null || typeof value !== 'object') {
227
+ return false;
228
+ }
229
+ const prototype = Object.getPrototypeOf(value);
230
+ return prototype === Object.prototype || prototype === null;
231
+ }
232
+
233
+ function isJsonValue(value: unknown, seen: readonly object[] = []): value is JsonValue {
234
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') {
235
+ return true;
236
+ }
237
+ if (typeof value === 'number') {
238
+ return Number.isFinite(value);
239
+ }
240
+ if (typeof value !== 'object') {
241
+ return false;
242
+ }
243
+ if (seen.includes(value)) {
244
+ return false;
245
+ }
246
+ if (Array.isArray(value)) {
247
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
248
+ return false;
249
+ }
250
+ const keys = Reflect.ownKeys(value);
251
+ if (keys.length !== value.length + 1 || !keys.includes('length')) {
252
+ return false;
253
+ }
254
+ for (let index = 0; index < value.length; index += 1) {
255
+ const key = String(index);
256
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
257
+ return false;
258
+ }
259
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
260
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
261
+ return false;
262
+ }
263
+ if (!isJsonValue(descriptor.value, [...seen, value])) {
264
+ return false;
265
+ }
266
+ }
267
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
268
+ return Boolean(lengthDescriptor && !lengthDescriptor.enumerable && !lengthDescriptor.configurable && lengthDescriptor.value === value.length);
269
+ }
270
+ if (!isPlainRecord(value)) {
271
+ return false;
272
+ }
273
+ for (const key of Reflect.ownKeys(value)) {
274
+ if (typeof key !== 'string') {
275
+ return false;
276
+ }
277
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
278
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
279
+ return false;
280
+ }
281
+ if (!isJsonValue(descriptor.value, [...seen, value])) {
282
+ return false;
283
+ }
284
+ }
285
+ return true;
286
+ }
287
+
288
+ function isJsonArray(value: unknown): value is readonly JsonValue[] {
289
+ return Array.isArray(value) && isJsonValue(value);
290
+ }
291
+
292
+ function hasDoneOutput(event: unknown): event is DoneActorEvent {
293
+ return isPlainRecord(event) && 'output' in event;
294
+ }
295
+
296
+ function hasErrorValue(event: unknown): event is ErrorActorEvent {
297
+ return isPlainRecord(event) && 'error' in event;
298
+ }
299
+
300
+ function isNonEmptyString(value: unknown): value is string {
301
+ return typeof value === 'string' && value.trim().length > 0;
302
+ }
303
+
304
+ function isRoutingQuestionOutput(output: unknown): output is Extract<CaptainOutput, { guard: 'question' | 'needsBossReply' }> {
305
+ return isPlainRecord(output) && (output.guard === 'question' || output.guard === 'needsBossReply') && isNonEmptyString(output.question);
306
+ }
307
+
308
+ function isReassessQuestionOutput(output: unknown): output is Extract<CaptainOutput, { guard: 'followUpQuestion' | 'needsBossReply' }> {
309
+ return isPlainRecord(output) && (output.guard === 'followUpQuestion' || output.guard === 'needsBossReply') && isNonEmptyString(output.question);
310
+ }
311
+
312
+ function isDelegationOutput(output: unknown): output is Extract<CaptainOutput, { guard: 'delegation' }> {
313
+ return (
314
+ isPlainRecord(output) &&
315
+ output.guard === 'delegation' &&
316
+ isJsonArray(output.remainingPlan) &&
317
+ isNonEmptyString(output.nextPlaybookId) &&
318
+ isNonEmptyString(output.nextPlaybookInput)
319
+ );
320
+ }
321
+
322
+ function isContinuingOutput(output: unknown): output is Extract<CaptainOutput, { guard: 'continuing' }> {
323
+ return (
324
+ isPlainRecord(output) &&
325
+ output.guard === 'continuing' &&
326
+ isJsonArray(output.remainingPlan) &&
327
+ isNonEmptyString(output.nextPlaybookId) &&
328
+ isNonEmptyString(output.nextPlaybookInput)
329
+ );
330
+ }
331
+
332
+ function isFinalOutput(output: unknown): output is Extract<CaptainOutput, { guard: 'final' }> {
333
+ return isPlainRecord(output) && output.guard === 'final' && isNonEmptyString(output.response);
334
+ }
335
+
336
+ function outputFrom(event: unknown): unknown {
337
+ return hasDoneOutput(event) ? event.output : undefined;
338
+ }
339
+
340
+ function errorFrom(event: unknown): unknown {
341
+ return hasErrorValue(event) ? event.error : undefined;
342
+ }
343
+
344
+ function targetInCatalog(context: Context, playbookId: string): boolean {
345
+ return context.enabledPlaybooks.some((entry) => entry.id === playbookId);
346
+ }
347
+
348
+ function callSignature(playbookId: string, text: string): string {
349
+ return JSON.stringify([playbookId, text]);
350
+ }
351
+
352
+ function canEnterDynamicCall(context: Context, playbookId: string, text: string): boolean {
353
+ return (
354
+ isNonEmptyString(playbookId) &&
355
+ isNonEmptyString(text) &&
356
+ playbookId !== context.selfPlaybookId &&
357
+ targetInCatalog(context, playbookId) &&
358
+ !context.callHistory.includes(callSignature(playbookId, text))
359
+ );
360
+ }
361
+
362
+ function normalizeError(error: unknown): NormalizedError {
363
+ if (error instanceof Error) {
364
+ const normalized: { name: string; message: string; stack?: string } = {
365
+ name: error.name || 'Error',
366
+ message: error.message || 'Unknown error',
367
+ };
368
+ if (typeof error.stack === 'string') {
369
+ normalized.stack = error.stack;
370
+ }
371
+ return normalized;
372
+ }
373
+ if (isPlainRecord(error) && typeof error.name === 'string' && typeof error.message === 'string') {
374
+ const normalized: { name: string; message: string; stack?: string } = {
375
+ name: error.name,
376
+ message: error.message,
377
+ };
378
+ if (typeof error.stack === 'string') {
379
+ normalized.stack = error.stack;
380
+ }
381
+ return normalized;
382
+ }
383
+ return { name: 'Error', message: String(error) };
384
+ }
385
+
386
+ function isNormalizedError(value: unknown): value is NormalizedError {
387
+ const allowed = new Set(['name', 'message', 'stack']);
388
+ return (
389
+ isPlainRecord(value) &&
390
+ Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key)) &&
391
+ isNonEmptyString(value.name) &&
392
+ typeof value.message === 'string' &&
393
+ (!('stack' in value) || typeof value.stack === 'string')
394
+ );
395
+ }
396
+
397
+ function isStringArray(value: unknown): value is readonly string[] {
398
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
399
+ }
400
+
401
+ function isPlaybookStateValue(value: unknown, seen: readonly object[] = []): value is PlaybookStateValue {
402
+ if (typeof value === 'string') {
403
+ return true;
404
+ }
405
+ if (!isPlainRecord(value) || seen.includes(value)) {
406
+ return false;
407
+ }
408
+ return Reflect.ownKeys(value).every((key) => {
409
+ if (typeof key !== 'string') {
410
+ return false;
411
+ }
412
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
413
+ return Boolean(
414
+ descriptor &&
415
+ descriptor.enumerable &&
416
+ 'value' in descriptor &&
417
+ isPlaybookStateValue(descriptor.value, [...seen, value]),
418
+ );
419
+ });
420
+ }
421
+
422
+ function isPlaybookState(value: unknown): value is PlaybookState {
423
+ if (!isPlainRecord(value)) {
424
+ return false;
425
+ }
426
+ const allowed = new Set(['value', 'activeStateIds', 'tags', 'status', 'quiescent', 'stateId']);
427
+ if (Reflect.ownKeys(value).some((key) => typeof key !== 'string' || !allowed.has(key))) {
428
+ return false;
429
+ }
430
+ return (
431
+ isPlaybookStateValue(value.value) &&
432
+ isStringArray(value.activeStateIds) &&
433
+ isStringArray(value.tags) &&
434
+ (value.status === 'active' || value.status === 'done' || value.status === 'error' || value.status === 'stopped') &&
435
+ typeof value.quiescent === 'boolean' &&
436
+ (!('stateId' in value) || typeof value.stateId === 'string')
437
+ );
438
+ }
439
+
440
+ function publicChildResult(error: unknown): Record<string, unknown> | undefined {
441
+ if (!(error instanceof Error) || !('result' in error)) {
442
+ return undefined;
443
+ }
444
+ const result = (error as Error & { readonly result?: unknown }).result;
445
+ return isPlainRecord(result) ? result : undefined;
446
+ }
447
+
448
+ function isValidPublicChildResult(error: unknown, context: Context): boolean {
449
+ const result = publicChildResult(error);
450
+ if (!result) {
451
+ return false;
452
+ }
453
+ const allowed = new Set(['playbookId', 'status', 'error', 'output', 'childSessionId', 'state']);
454
+ if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.has(key))) {
455
+ return false;
456
+ }
457
+ if (result.playbookId !== context.nextPlaybookId || (result.status !== 'aborted' && result.status !== 'error')) {
458
+ return false;
459
+ }
460
+ if ('output' in result) {
461
+ return false;
462
+ }
463
+ if ('childSessionId' in result && !isNonEmptyString(result.childSessionId)) {
464
+ return false;
465
+ }
466
+ if ('state' in result && !isPlaybookState(result.state)) {
467
+ return false;
468
+ }
469
+ if (result.status === 'error') {
470
+ return isNormalizedError(result.error);
471
+ }
472
+ return !('error' in result) || isNormalizedError(result.error);
473
+ }
474
+
475
+ function compactChildError(error: unknown): NormalizedError {
476
+ const result = publicChildResult(error);
477
+ if (result && isNormalizedError(result.error)) {
478
+ return { name: result.error.name, message: result.error.message };
479
+ }
480
+ return { name: 'AbortError', message: 'Child playbook aborted.' };
481
+ }
482
+
483
+ function childStatus(error: unknown): 'aborted' | 'error' {
484
+ const result = publicChildResult(error);
485
+ return result?.status === 'error' ? 'error' : 'aborted';
486
+ }
487
+
488
+ function makePendingQuestion(stateId: ResumableStateId, sourceItem: 'CAPTAIN-1' | 'CAPTAIN-3', question: string): PendingBossQuestion {
489
+ return {
490
+ questionId: stateId,
491
+ resumeStateId: stateId,
492
+ sourceItem,
493
+ player: 'Captain',
494
+ question,
495
+ };
496
+ }
497
+
498
+ function bossIntentFromEvent(event: CaptainMachineEvent): string {
499
+ return event.type === 'BOSS_INTENT' || event.type === 'BOSS_INTERRUPT' ? event.bossIntent : '';
500
+ }
501
+
502
+ function answerMatchesPending(context: Context, event: CaptainMachineEvent): boolean {
503
+ if (event.type !== 'BOSS_REPLY' || !context.pendingBossQuestion || !isNonEmptyString(event.answer)) {
504
+ return false;
505
+ }
506
+ return event.questionId === undefined || event.questionId === context.pendingBossQuestion.questionId;
507
+ }
508
+
509
+ function bossInterrupts(ids: readonly 'routing'[]) {
510
+ return ids.map((id) => ({
511
+ guard: 'isRoutingInterrupt' as const,
512
+ target: `#${id}` as const,
513
+ reenter: true as const,
514
+ actions: 'startRoutingFromBoss' as const,
515
+ }));
516
+ }
517
+
518
+ function resumableStates() {
519
+ return [
520
+ {
521
+ guard: 'canResumeRouting',
522
+ target: '#routing',
523
+ actions: 'storeBossReply',
524
+ },
525
+ {
526
+ guard: 'canResumeReassessing',
527
+ target: '#reassessing',
528
+ actions: 'storeBossReply',
529
+ },
530
+ {
531
+ target: 'failed',
532
+ actions: 'rememberInvalidBossReply',
533
+ },
534
+ ] as const;
535
+ }
536
+
537
+ export const captainMachine = setup({
538
+ types: {} as {
539
+ context: Context;
540
+ events: CaptainMachineEvent;
541
+ input: CaptainMachineInput;
542
+ output: CaptainMachineOutput;
543
+ },
544
+ actors: {
545
+ captain: fromPromise<CaptainOutput, CaptainInput>(() => {
546
+ throw new Error('captain actor must be provided by the runner');
547
+ }),
548
+ playbook: fromPromise<PlaybookOutput, PlaybookInput>(() => {
549
+ throw new Error('playbook actor must be provided by the runner');
550
+ }),
551
+ },
552
+ guards: {
553
+ hasBossIntent: ({ event }) => event.type === 'BOSS_INTENT' && isNonEmptyString(event.bossIntent),
554
+ isRoutingInterrupt: ({ event }) => event.type === 'BOSS_INTERRUPT' && event.targetId === 'routing' && isNonEmptyString(event.bossIntent),
555
+ canResumeRouting: ({ context, event }) => answerMatchesPending(context, event) && context.pendingBossQuestion?.resumeStateId === 'routing',
556
+ canResumeReassessing: ({ context, event }) => answerMatchesPending(context, event) && context.pendingBossQuestion?.resumeStateId === 'reassessing',
557
+ isRoutingQuestion: ({ event }) => isRoutingQuestionOutput(outputFrom(event)),
558
+ isRoutingDelegation: ({ context, event }) => {
559
+ const output = outputFrom(event);
560
+ return isDelegationOutput(output) && canEnterDynamicCall(context, output.nextPlaybookId, output.nextPlaybookInput);
561
+ },
562
+ isReassessFinal: ({ event }) => isFinalOutput(outputFrom(event)),
563
+ isReassessQuestion: ({ event }) => isReassessQuestionOutput(outputFrom(event)),
564
+ isReassessContinuing: ({ context, event }) => {
565
+ const output = outputFrom(event);
566
+ return (
567
+ isContinuingOutput(output) &&
568
+ output.remainingPlan.length < context.remainingPlan.length &&
569
+ canEnterDynamicCall(context, output.nextPlaybookId, output.nextPlaybookInput)
570
+ );
571
+ },
572
+ isPlaybookSuccessOutput: ({ event }) => {
573
+ const output = outputFrom(event);
574
+ return output === undefined || isJsonValue(output);
575
+ },
576
+ isAuthoredChildError: ({ context, event }) => isValidPublicChildResult(errorFrom(event), context),
577
+ },
578
+ actions: {
579
+ startRoutingFromBoss: assign(({ event }) => ({
580
+ bossIntent: bossIntentFromEvent(event),
581
+ remainingPlan: [],
582
+ completedCallResults: [],
583
+ nextPlaybookId: '',
584
+ nextPlaybookInput: '',
585
+ callHistory: [],
586
+ response: undefined,
587
+ pendingBossQuestion: undefined,
588
+ bossReply: undefined,
589
+ lastError: undefined,
590
+ })),
591
+ storeBossReply: assign(({ event }) => ({
592
+ bossReply: event.type === 'BOSS_REPLY' ? event.answer : undefined,
593
+ lastError: undefined,
594
+ })),
595
+ setRoutingQuestion: assign(({ event }) => {
596
+ const output = outputFrom(event);
597
+ return {
598
+ pendingBossQuestion: isRoutingQuestionOutput(output) ? makePendingQuestion('routing', 'CAPTAIN-1', output.question) : undefined,
599
+ bossReply: undefined,
600
+ };
601
+ }),
602
+ storeRoutingDelegation: assign(({ context, event }) => {
603
+ const output = outputFrom(event);
604
+ if (!isDelegationOutput(output)) {
605
+ return {};
606
+ }
607
+ return {
608
+ remainingPlan: output.remainingPlan,
609
+ nextPlaybookId: output.nextPlaybookId,
610
+ nextPlaybookInput: output.nextPlaybookInput,
611
+ callHistory: [...context.callHistory, callSignature(output.nextPlaybookId, output.nextPlaybookInput)],
612
+ pendingBossQuestion: undefined,
613
+ bossReply: undefined,
614
+ lastError: undefined,
615
+ };
616
+ }),
617
+ appendSuccessfulChildResult: assign(({ context, event }) => {
618
+ const output = outputFrom(event);
619
+ const result: CompletedCallResult = isJsonValue(output)
620
+ ? { playbookId: context.nextPlaybookId, status: 'ok', output }
621
+ : { playbookId: context.nextPlaybookId, status: 'ok' };
622
+ return {
623
+ completedCallResults: [...context.completedCallResults, result],
624
+ lastError: undefined,
625
+ };
626
+ }),
627
+ appendRejectedChildResult: assign(({ context, event }) => {
628
+ const error = errorFrom(event);
629
+ const result: CompletedCallResult = {
630
+ playbookId: context.nextPlaybookId,
631
+ status: childStatus(error),
632
+ error: compactChildError(error),
633
+ };
634
+ return {
635
+ completedCallResults: [...context.completedCallResults, result],
636
+ lastError: undefined,
637
+ };
638
+ }),
639
+ storeFinalResponse: assign(({ event }) => {
640
+ const output = outputFrom(event);
641
+ return isFinalOutput(output)
642
+ ? {
643
+ response: output.response,
644
+ pendingBossQuestion: undefined,
645
+ bossReply: undefined,
646
+ lastError: undefined,
647
+ }
648
+ : {};
649
+ }),
650
+ setReassessQuestion: assign(({ event }) => {
651
+ const output = outputFrom(event);
652
+ return {
653
+ pendingBossQuestion: isReassessQuestionOutput(output) ? makePendingQuestion('reassessing', 'CAPTAIN-3', output.question) : undefined,
654
+ bossReply: undefined,
655
+ };
656
+ }),
657
+ storeContinuingCall: assign(({ context, event }) => {
658
+ const output = outputFrom(event);
659
+ if (!isContinuingOutput(output)) {
660
+ return {};
661
+ }
662
+ return {
663
+ remainingPlan: output.remainingPlan,
664
+ nextPlaybookId: output.nextPlaybookId,
665
+ nextPlaybookInput: output.nextPlaybookInput,
666
+ callHistory: [...context.callHistory, callSignature(output.nextPlaybookId, output.nextPlaybookInput)],
667
+ pendingBossQuestion: undefined,
668
+ bossReply: undefined,
669
+ lastError: undefined,
670
+ };
671
+ }),
672
+ rememberInvalidActorOutput: assign({
673
+ lastError: () => ({ name: 'ActorOutputError', message: 'Actor output did not match any declared result contract.' }),
674
+ }),
675
+ rememberInvalidBossReply: assign({
676
+ lastError: () => ({ name: 'BossReplyError', message: 'BOSS_REPLY did not match a pending question or carried an empty answer.' }),
677
+ }),
678
+ rememberActorError: assign(({ event }) => ({
679
+ lastError: normalizeError(errorFrom(event)),
680
+ })),
681
+ },
682
+ }).createMachine({
683
+ id: 'captain',
684
+ initial: 'ready',
685
+ context: ({ input }) => ({
686
+ bossIntent: input.bossIntent ?? '',
687
+ enabledPlaybooks: input.enabledPlaybooks,
688
+ selfPlaybookId: input.selfPlaybookId,
689
+ remainingPlan: [],
690
+ completedCallResults: [],
691
+ nextPlaybookId: '',
692
+ nextPlaybookInput: '',
693
+ callHistory: [],
694
+ }),
695
+ output: ({ context }) => ({
696
+ response: context.response ?? '',
697
+ }),
698
+ on: {
699
+ BOSS_INTERRUPT: bossInterrupts(['routing']),
700
+ },
701
+ states: {
702
+ ready: {
703
+ id: 'ready',
704
+ description: 'Idle hub waiting for Boss to provide a new intent.',
705
+ tags: ['playbook.parked'],
706
+ meta: { playbook: { stateId: 'ready', description: 'Idle hub waiting for Boss to provide a new intent.' } },
707
+ on: {
708
+ BOSS_INTENT: {
709
+ guard: 'hasBossIntent',
710
+ target: 'routing',
711
+ actions: 'startRoutingFromBoss',
712
+ },
713
+ },
714
+ },
715
+ routing: {
716
+ id: 'routing',
717
+ description: 'Captain routes the Boss intent to a first enabled playbook or asks one routing question.',
718
+ tags: ['playbook.busy'],
719
+ meta: {
720
+ playbook: {
721
+ stateId: 'routing',
722
+ description: 'Captain routes the Boss intent to a first enabled playbook or asks one routing question.',
723
+ },
724
+ },
725
+ invoke: {
726
+ src: 'captain',
727
+ input: ({ context }): CaptainInput => ({
728
+ ...{
729
+ stateId: 'routing',
730
+ sourceItem: 'CAPTAIN-1',
731
+ prompt: ROUTING_PROMPT,
732
+ result: ROUTING_RESULTS,
733
+ bossIntent: context.bossIntent,
734
+ enabledPlaybooks: context.enabledPlaybooks,
735
+ },
736
+ ...(context.pendingBossQuestion ? { pendingBossQuestion: context.pendingBossQuestion } : {}),
737
+ ...(context.bossReply ? { bossReply: context.bossReply } : {}),
738
+ }),
739
+ onDone: [
740
+ { guard: 'isRoutingQuestion', target: 'awaitBossReply', actions: 'setRoutingQuestion' },
741
+ { guard: 'isRoutingDelegation', target: 'callPlaybook', actions: 'storeRoutingDelegation' },
742
+ { target: 'failed', actions: 'rememberInvalidActorOutput' },
743
+ ],
744
+ onError: { target: 'failed', actions: 'rememberActorError' },
745
+ },
746
+ },
747
+ callPlaybook: {
748
+ id: 'callPlaybook',
749
+ description: 'Captain calls the selected enabled playbook with the selected standalone request.',
750
+ tags: ['playbook.suspended'],
751
+ meta: {
752
+ playbook: {
753
+ stateId: 'callPlaybook',
754
+ description: 'Captain calls the selected enabled playbook with the selected standalone request.',
755
+ },
756
+ },
757
+ invoke: {
758
+ src: 'playbook',
759
+ input: ({ context }): PlaybookInput => ({
760
+ stateId: 'callPlaybook',
761
+ sourceItem: 'CAPTAIN-2',
762
+ playbookId: context.nextPlaybookId,
763
+ text: context.nextPlaybookInput,
764
+ playbookIdContext: 'nextPlaybookId',
765
+ textContext: 'nextPlaybookInput',
766
+ }),
767
+ onDone: [
768
+ { guard: 'isPlaybookSuccessOutput', target: 'reassessing', actions: 'appendSuccessfulChildResult' },
769
+ { target: 'failed', actions: 'rememberInvalidActorOutput' },
770
+ ],
771
+ onError: [
772
+ { guard: 'isAuthoredChildError', target: 'reassessing', actions: 'appendRejectedChildResult' },
773
+ { target: 'failed', actions: 'rememberActorError' },
774
+ ],
775
+ },
776
+ },
777
+ reassessing: {
778
+ id: 'reassessing',
779
+ description: 'Captain reassesses the original intent, remaining plan, and completed call results.',
780
+ tags: ['playbook.busy'],
781
+ meta: {
782
+ playbook: {
783
+ stateId: 'reassessing',
784
+ description: 'Captain reassesses the original intent, remaining plan, and completed call results.',
785
+ },
786
+ },
787
+ invoke: {
788
+ src: 'captain',
789
+ input: ({ context }): CaptainInput => ({
790
+ ...{
791
+ stateId: 'reassessing',
792
+ sourceItem: 'CAPTAIN-3',
793
+ prompt: REASSESS_PROMPT,
794
+ result: REASSESS_RESULTS,
795
+ bossIntent: context.bossIntent,
796
+ enabledPlaybooks: context.enabledPlaybooks,
797
+ remainingPlan: context.remainingPlan,
798
+ completedCallResults: context.completedCallResults,
799
+ },
800
+ ...(context.pendingBossQuestion ? { pendingBossQuestion: context.pendingBossQuestion } : {}),
801
+ ...(context.bossReply ? { bossReply: context.bossReply } : {}),
802
+ }),
803
+ onDone: [
804
+ { guard: 'isReassessFinal', target: 'done', actions: 'storeFinalResponse' },
805
+ { guard: 'isReassessQuestion', target: 'awaitBossReply', actions: 'setReassessQuestion' },
806
+ { guard: 'isReassessContinuing', target: 'callPlaybook', actions: 'storeContinuingCall' },
807
+ { target: 'failed', actions: 'rememberInvalidActorOutput' },
808
+ ],
809
+ onError: { target: 'failed', actions: 'rememberActorError' },
810
+ },
811
+ },
812
+ awaitBossReply: {
813
+ id: 'awaitBossReply',
814
+ description: "Waiting for Boss to answer the acting agent's question.",
815
+ tags: ['playbook.parked'],
816
+ meta: {
817
+ playbook: {
818
+ stateId: 'awaitBossReply',
819
+ description: "Waiting for Boss to answer the acting agent's question.",
820
+ },
821
+ },
822
+ on: {
823
+ BOSS_REPLY: resumableStates(),
824
+ BOSS_INTENT: {
825
+ guard: 'hasBossIntent',
826
+ target: 'routing',
827
+ actions: 'startRoutingFromBoss',
828
+ },
829
+ },
830
+ },
831
+ failed: {
832
+ id: 'failed',
833
+ description: 'Recoverable failure retaining context for Boss recovery.',
834
+ tags: ['playbook.parked'],
835
+ meta: { playbook: { stateId: 'failed', description: 'Recoverable failure retaining context for Boss recovery.' } },
836
+ on: {
837
+ BOSS_INTENT: {
838
+ guard: 'hasBossIntent',
839
+ target: 'routing',
840
+ actions: 'startRoutingFromBoss',
841
+ },
842
+ },
843
+ },
844
+ done: {
845
+ id: 'done',
846
+ type: 'final',
847
+ description: 'Captain completed with a concise response for Boss.',
848
+ meta: { playbook: { stateId: 'done', description: 'Captain completed with a concise response for Boss.' } },
849
+ },
850
+ },
851
+ });