@sublang/playbook 12.2.2 → 13.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 (38) hide show
  1. package/docs/cli.md +56 -52
  2. package/docs/configuration.md +2 -2
  3. package/docs/embedding.md +68 -32
  4. package/package.json +11 -3
  5. package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
  6. package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
  7. package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
  8. package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
  9. package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
  10. package/reference/sdlc/code.playbook/bin/run.js +62 -18
  11. package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
  12. package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
  13. package/reference/sdlc/code.playbook/code.fsm.js +43 -5
  14. package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
  15. package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
  17. package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
  18. package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
  19. package/reference/sdlc/code.playbook/session-host.js +18 -0
  20. package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
  21. package/reference/sdlc/code.playbook/session-store.js +22 -6
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
  24. package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
  25. package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
  26. package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
  27. package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
  28. package/reference/sdlc/review.playbook/review.fsm.js +11 -1
  29. package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
  30. package/slc/gears2fsm.md +21 -3
  31. package/slc/link.md +21 -2
  32. package/src/runtime.d.ts +7 -0
  33. package/src/runtime.ts +12 -0
  34. package/src/xstate-playbook-runtime.d.ts +13 -0
  35. package/src/xstate-playbook-runtime.js +75 -13
  36. package/src/xstate-playbook-runtime.ts +87 -21
  37. package/src/xstate-runtime.js +35 -4
  38. package/src/xstate-runtime.ts +49 -4
@@ -19,7 +19,7 @@ import { randomUUID } from 'node:crypto';
19
19
  import PQueue from 'p-queue';
20
20
  import { createActor, fromPromise } from 'xstate';
21
21
  import { createAcceptedOutcomeConsumer, } from '../../../src/accepted-outcome.js';
22
- import { assertJsonSafe, assertPlaybookEffectLedger, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, renderGovernedOutcomeContract, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
22
+ import { assertJsonSafe, assertPlaybookEffectLedger, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, renderGovernedOutcomeContract, snapshotJsonValue, snapshotPlaybookSession, terminalOutcomesFromMachine, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
23
23
  import decideMachine from './decide.fsm.js';
24
24
  function snapshotDecideRuntimeOptions(value) {
25
25
  const captured = snapshotJsonValue(value, 'DECIDE runtime options');
@@ -54,6 +54,9 @@ function authoredStateDescriptions(states) {
54
54
  return Object.freeze(descriptions);
55
55
  }
56
56
  const STATE_DESCRIPTIONS = authoredStateDescriptions(decideMachine.config.states);
57
+ // DR-048: each root final state's compiled terminal kind, read from this
58
+ // artifact through the shared reader the factory-backed runtimes use.
59
+ const TERMINAL_KINDS = terminalOutcomesFromMachine(decideMachine, 'DECIDE');
57
60
  const ROLE_STATES = [
58
61
  { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
59
62
  {
@@ -2814,18 +2817,33 @@ function createDecidePlaybookRuntime(options, deferredEffects) {
2814
2817
  const output = snapshot.output;
2815
2818
  if (output !== undefined)
2816
2819
  assertJsonSafe(output, 'terminal output');
2817
- const stateDescription = state.activeStateIds.includes('done')
2818
- ? STATE_DESCRIPTIONS.done
2820
+ const finalStateId = state.activeStateIds.includes('done')
2821
+ ? 'done'
2819
2822
  : state.activeStateIds.includes('reportedReviewFailure')
2820
- ? STATE_DESCRIPTIONS.reportedReviewFailure
2823
+ ? 'reportedReviewFailure'
2821
2824
  : undefined;
2822
- if (stateDescription === undefined) {
2825
+ const stateDescription = finalStateId === undefined
2826
+ ? undefined
2827
+ : STATE_DESCRIPTIONS[finalStateId];
2828
+ if (finalStateId === undefined || stateDescription === undefined) {
2823
2829
  throw new Error('decide runtime: completed actor has no authored final-state description');
2824
2830
  }
2831
+ // DR-048: the reached final state's compiled terminal kind, read from
2832
+ // this artifact exactly as the shared factory reads it.
2833
+ const terminalKind = TERMINAL_KINDS.get(finalStateId);
2825
2834
  return {
2826
2835
  outcome: 'terminal',
2827
2836
  state,
2828
2837
  stateDescription,
2838
+ ...(terminalKind === undefined
2839
+ ? {}
2840
+ : {
2841
+ terminal: {
2842
+ stateId: finalStateId,
2843
+ kind: terminalKind,
2844
+ description: stateDescription,
2845
+ },
2846
+ }),
2829
2847
  ...(output === undefined ? {} : { output }),
2830
2848
  };
2831
2849
  }
@@ -41,6 +41,7 @@ import {
41
41
  renderGovernedOutcomeContract,
42
42
  snapshotJsonValue,
43
43
  snapshotPlaybookSession,
44
+ terminalOutcomesFromMachine,
44
45
  validatePlayerResult,
45
46
  waitForPlaybookQuiescence,
46
47
  } from '../../../src/xstate-runtime.js';
@@ -190,6 +191,10 @@ const STATE_DESCRIPTIONS = authoredStateDescriptions(
190
191
  | undefined,
191
192
  );
192
193
 
194
+ // DR-048: each root final state's compiled terminal kind, read from this
195
+ // artifact through the shared reader the factory-backed runtimes use.
196
+ const TERMINAL_KINDS = terminalOutcomesFromMachine(decideMachine, 'DECIDE');
197
+
193
198
  const ROLE_STATES = [
194
199
  { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
195
200
  {
@@ -4247,20 +4252,36 @@ function createDecidePlaybookRuntime(
4247
4252
  if (snapshot.status === 'done') {
4248
4253
  const output = (snapshot as { output?: unknown }).output;
4249
4254
  if (output !== undefined) assertJsonSafe(output, 'terminal output');
4250
- const stateDescription = state.activeStateIds.includes('done')
4251
- ? STATE_DESCRIPTIONS.done
4255
+ const finalStateId = state.activeStateIds.includes('done')
4256
+ ? 'done'
4252
4257
  : state.activeStateIds.includes('reportedReviewFailure')
4253
- ? STATE_DESCRIPTIONS.reportedReviewFailure
4258
+ ? 'reportedReviewFailure'
4254
4259
  : undefined;
4255
- if (stateDescription === undefined) {
4260
+ const stateDescription =
4261
+ finalStateId === undefined
4262
+ ? undefined
4263
+ : STATE_DESCRIPTIONS[finalStateId];
4264
+ if (finalStateId === undefined || stateDescription === undefined) {
4256
4265
  throw new Error(
4257
4266
  'decide runtime: completed actor has no authored final-state description',
4258
4267
  );
4259
4268
  }
4269
+ // DR-048: the reached final state's compiled terminal kind, read from
4270
+ // this artifact exactly as the shared factory reads it.
4271
+ const terminalKind = TERMINAL_KINDS.get(finalStateId);
4260
4272
  return {
4261
4273
  outcome: 'terminal',
4262
4274
  state,
4263
4275
  stateDescription,
4276
+ ...(terminalKind === undefined
4277
+ ? {}
4278
+ : {
4279
+ terminal: {
4280
+ stateId: finalStateId,
4281
+ kind: terminalKind,
4282
+ description: stateDescription,
4283
+ },
4284
+ }),
4264
4285
  ...(output === undefined ? {} : { output }),
4265
4286
  };
4266
4287
  }
@@ -47,6 +47,18 @@ function playbookMeta(stateId, role) {
47
47
  },
48
48
  };
49
49
  }
50
+ // DR-048: a final state additionally declares whether its outcome means the
51
+ // workflow succeeded or failed, so DEV's own caller learns that from the
52
+ // machine rather than from DEV's output fields.
53
+ function terminalMeta(stateId, terminal) {
54
+ return {
55
+ playbook: {
56
+ stateId,
57
+ description: STATE_DESCRIPTIONS[stateId],
58
+ terminal,
59
+ },
60
+ };
61
+ }
50
62
  function isRecord(value) {
51
63
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
52
64
  return false;
@@ -172,6 +184,16 @@ function nestedResultFromError(error) {
172
184
  const result = error.result;
173
185
  return isRecord(result) ? result : undefined;
174
186
  }
187
+ function isFailureTerminalRecord(value) {
188
+ if (!isRecord(value))
189
+ return false;
190
+ const allowed = new Set(['stateId', 'kind', 'description']);
191
+ return (Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key)) &&
192
+ isNonEmptyString(value.stateId) &&
193
+ value.kind === 'failure' &&
194
+ (!Object.prototype.hasOwnProperty.call(value, 'description') ||
195
+ typeof value.description === 'string'));
196
+ }
175
197
  function normalizedChildFailure(error, playbookId) {
176
198
  const result = nestedResultFromError(error);
177
199
  if (result === undefined)
@@ -181,10 +203,12 @@ function normalizedChildFailure(error, playbookId) {
181
203
  'playbookId',
182
204
  'childSessionId',
183
205
  'state',
184
- 'error',
206
+ ...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
185
207
  ]);
186
208
  if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.has(key)) ||
187
- (result.status !== 'aborted' && result.status !== 'error') ||
209
+ (result.status !== 'aborted' &&
210
+ result.status !== 'error' &&
211
+ result.status !== 'ok') ||
188
212
  result.playbookId !== playbookId) {
189
213
  return undefined;
190
214
  }
@@ -192,6 +216,21 @@ function normalizedChildFailure(error, playbookId) {
192
216
  !isNonEmptyString(result.childSessionId)) {
193
217
  return undefined;
194
218
  }
219
+ if (result.status === 'ok') {
220
+ if (!Object.prototype.hasOwnProperty.call(result, 'terminal') ||
221
+ !isFailureTerminalRecord(result.terminal) ||
222
+ !isNonEmptyString(result.childSessionId) ||
223
+ (Object.prototype.hasOwnProperty.call(result, 'state') &&
224
+ !isPlaybookState(result.state))) {
225
+ return undefined;
226
+ }
227
+ return {
228
+ status: 'ok',
229
+ ...(result.output === undefined
230
+ ? {}
231
+ : { output: result.output }),
232
+ };
233
+ }
195
234
  if (Object.prototype.hasOwnProperty.call(result, 'state') &&
196
235
  !isPlaybookState(result.state)) {
197
236
  return undefined;
@@ -233,8 +272,9 @@ function authoredChildFailureGuard(playbookId) {
233
272
  function authoredChildError(event, playbookId) {
234
273
  const outer = isRecord(event) ? event.error : undefined;
235
274
  const failure = normalizedChildFailure(outer, playbookId);
236
- if (failure?.error !== undefined)
275
+ if (failure !== undefined && failure.status !== 'ok' && failure.error) {
237
276
  return failure.error;
277
+ }
238
278
  if (failure?.status === 'aborted') {
239
279
  return {
240
280
  name: 'AbortError',
@@ -249,9 +289,19 @@ function authoredChildError(event, playbookId) {
249
289
  function relayedChildFailure(event, playbookId) {
250
290
  const outer = isRecord(event) ? event.error : undefined;
251
291
  const failure = normalizedChildFailure(outer, playbookId);
292
+ // DR-048: a completed child that reached an authored failure terminal
293
+ // relays its own output, exactly as a terminal result that did not prove
294
+ // success did before it was routed through the error path.
295
+ if (failure?.status === 'ok') {
296
+ return {
297
+ playbookId,
298
+ status: 'ok',
299
+ ...(failure.output === undefined ? {} : { output: failure.output }),
300
+ };
301
+ }
252
302
  return {
253
303
  playbookId,
254
- status: failure?.status ?? 'error',
304
+ status: failure?.status === 'aborted' ? 'aborted' : 'error',
255
305
  error: authoredChildError(event, playbookId),
256
306
  };
257
307
  }
@@ -703,19 +753,19 @@ export const devMachine = machineSetup.createMachine({
703
753
  discussionComplete: {
704
754
  id: 'discussionComplete',
705
755
  description: STATE_DESCRIPTIONS.discussionComplete,
706
- meta: playbookMeta('discussionComplete'),
756
+ meta: terminalMeta('discussionComplete', 'success'),
707
757
  type: 'final',
708
758
  },
709
759
  done: {
710
760
  id: 'done',
711
761
  description: STATE_DESCRIPTIONS.done,
712
- meta: playbookMeta('done'),
762
+ meta: terminalMeta('done', 'success'),
713
763
  type: 'final',
714
764
  },
715
765
  reportedChildFailure: {
716
766
  id: 'reportedChildFailure',
717
767
  description: STATE_DESCRIPTIONS.reportedChildFailure,
718
- meta: playbookMeta('reportedChildFailure'),
768
+ meta: terminalMeta('reportedChildFailure', 'failure'),
719
769
  type: 'final',
720
770
  },
721
771
  },
@@ -197,6 +197,22 @@ function playbookMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
197
197
  };
198
198
  }
199
199
 
200
+ // DR-048: a final state additionally declares whether its outcome means the
201
+ // workflow succeeded or failed, so DEV's own caller learns that from the
202
+ // machine rather than from DEV's output fields.
203
+ function terminalMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
204
+ stateId: StateId,
205
+ terminal: 'success' | 'failure',
206
+ ) {
207
+ return {
208
+ playbook: {
209
+ stateId,
210
+ description: STATE_DESCRIPTIONS[stateId],
211
+ terminal,
212
+ },
213
+ };
214
+ }
215
+
200
216
  function isRecord(value: unknown): value is Record<string, unknown> {
201
217
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
202
218
  return false;
@@ -367,10 +383,32 @@ function nestedResultFromError(
367
383
  return isRecord(result) ? result : undefined;
368
384
  }
369
385
 
370
- type AuthoredChildFailure = {
371
- readonly status: 'aborted' | 'error';
372
- readonly error?: CompactError;
373
- };
386
+ type AuthoredChildFailure =
387
+ | {
388
+ readonly status: 'aborted' | 'error';
389
+ readonly error?: CompactError;
390
+ }
391
+ // DR-048: a child that completed at an authored failure terminal. The
392
+ // bridge rejects it through the same error path, so DEV recognizes the
393
+ // failure from the child's own machine and never from its output fields.
394
+ | {
395
+ readonly status: 'ok';
396
+ readonly output?: JsonValue;
397
+ };
398
+
399
+ function isFailureTerminalRecord(value: unknown): boolean {
400
+ if (!isRecord(value)) return false;
401
+ const allowed = new Set(['stateId', 'kind', 'description']);
402
+ return (
403
+ Reflect.ownKeys(value).every(
404
+ (key) => typeof key === 'string' && allowed.has(key),
405
+ ) &&
406
+ isNonEmptyString(value.stateId) &&
407
+ value.kind === 'failure' &&
408
+ (!Object.prototype.hasOwnProperty.call(value, 'description') ||
409
+ typeof value.description === 'string')
410
+ );
411
+ }
374
412
 
375
413
  function normalizedChildFailure(
376
414
  error: unknown,
@@ -383,13 +421,15 @@ function normalizedChildFailure(
383
421
  'playbookId',
384
422
  'childSessionId',
385
423
  'state',
386
- 'error',
424
+ ...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
387
425
  ]);
388
426
  if (
389
427
  Reflect.ownKeys(result).some(
390
428
  (key) => typeof key !== 'string' || !allowed.has(key),
391
429
  ) ||
392
- (result.status !== 'aborted' && result.status !== 'error') ||
430
+ (result.status !== 'aborted' &&
431
+ result.status !== 'error' &&
432
+ result.status !== 'ok') ||
393
433
  result.playbookId !== playbookId
394
434
  ) {
395
435
  return undefined;
@@ -400,6 +440,23 @@ function normalizedChildFailure(
400
440
  ) {
401
441
  return undefined;
402
442
  }
443
+ if (result.status === 'ok') {
444
+ if (
445
+ !Object.prototype.hasOwnProperty.call(result, 'terminal') ||
446
+ !isFailureTerminalRecord(result.terminal) ||
447
+ !isNonEmptyString(result.childSessionId) ||
448
+ (Object.prototype.hasOwnProperty.call(result, 'state') &&
449
+ !isPlaybookState(result.state))
450
+ ) {
451
+ return undefined;
452
+ }
453
+ return {
454
+ status: 'ok',
455
+ ...(result.output === undefined
456
+ ? {}
457
+ : { output: result.output as JsonValue }),
458
+ };
459
+ }
403
460
  if (
404
461
  Object.prototype.hasOwnProperty.call(result, 'state') &&
405
462
  !isPlaybookState(result.state)
@@ -453,7 +510,9 @@ function authoredChildError(
453
510
  ): CompactError {
454
511
  const outer = isRecord(event) ? event.error : undefined;
455
512
  const failure = normalizedChildFailure(outer, playbookId);
456
- if (failure?.error !== undefined) return failure.error;
513
+ if (failure !== undefined && failure.status !== 'ok' && failure.error) {
514
+ return failure.error;
515
+ }
457
516
  if (failure?.status === 'aborted') {
458
517
  return {
459
518
  name: 'AbortError',
@@ -472,9 +531,19 @@ function relayedChildFailure(
472
531
  ): CompletedChildResult {
473
532
  const outer = isRecord(event) ? event.error : undefined;
474
533
  const failure = normalizedChildFailure(outer, playbookId);
534
+ // DR-048: a completed child that reached an authored failure terminal
535
+ // relays its own output, exactly as a terminal result that did not prove
536
+ // success did before it was routed through the error path.
537
+ if (failure?.status === 'ok') {
538
+ return {
539
+ playbookId,
540
+ status: 'ok',
541
+ ...(failure.output === undefined ? {} : { output: failure.output }),
542
+ };
543
+ }
475
544
  return {
476
545
  playbookId,
477
- status: failure?.status ?? 'error',
546
+ status: failure?.status === 'aborted' ? 'aborted' : 'error',
478
547
  error: authoredChildError(event, playbookId),
479
548
  };
480
549
  }
@@ -967,19 +1036,19 @@ export const devMachine = machineSetup.createMachine({
967
1036
  discussionComplete: {
968
1037
  id: 'discussionComplete',
969
1038
  description: STATE_DESCRIPTIONS.discussionComplete,
970
- meta: playbookMeta('discussionComplete'),
1039
+ meta: terminalMeta('discussionComplete', 'success'),
971
1040
  type: 'final',
972
1041
  },
973
1042
  done: {
974
1043
  id: 'done',
975
1044
  description: STATE_DESCRIPTIONS.done,
976
- meta: playbookMeta('done'),
1045
+ meta: terminalMeta('done', 'success'),
977
1046
  type: 'final',
978
1047
  },
979
1048
  reportedChildFailure: {
980
1049
  id: 'reportedChildFailure',
981
1050
  description: STATE_DESCRIPTIONS.reportedChildFailure,
982
- meta: playbookMeta('reportedChildFailure'),
1051
+ meta: terminalMeta('reportedChildFailure', 'failure'),
983
1052
  type: 'final',
984
1053
  },
985
1054
  },
@@ -104,6 +104,16 @@ const playbookMeta = (stateId, role) => ({
104
104
  ...(role === undefined ? {} : { role }),
105
105
  },
106
106
  });
107
+ // DR-048: a final state additionally declares whether its outcome means the
108
+ // workflow succeeded or failed, so a caller learns that from the machine
109
+ // rather than from REVIEW's output fields.
110
+ const terminalMeta = (stateId, terminal) => ({
111
+ playbook: {
112
+ stateId,
113
+ description: stateDescriptions[stateId],
114
+ terminal,
115
+ },
116
+ });
107
117
  const outputOf = (event) => event.output;
108
118
  const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
109
119
  const stateMetadata = {
@@ -685,7 +695,7 @@ export const reviewMachine = setup({
685
695
  done: {
686
696
  id: 'done',
687
697
  description: stateDescriptions.done,
688
- meta: playbookMeta('done'),
698
+ meta: terminalMeta('done', 'success'),
689
699
  type: 'final',
690
700
  },
691
701
  },
@@ -212,6 +212,20 @@ const playbookMeta = <StateId extends keyof typeof stateDescriptions>(
212
212
  },
213
213
  });
214
214
 
215
+ // DR-048: a final state additionally declares whether its outcome means the
216
+ // workflow succeeded or failed, so a caller learns that from the machine
217
+ // rather than from REVIEW's output fields.
218
+ const terminalMeta = <StateId extends keyof typeof stateDescriptions>(
219
+ stateId: StateId,
220
+ terminal: 'success' | 'failure',
221
+ ) => ({
222
+ playbook: {
223
+ stateId,
224
+ description: stateDescriptions[stateId],
225
+ terminal,
226
+ },
227
+ });
228
+
215
229
  const outputOf = (event: unknown): PlayerOutput =>
216
230
  (event as PlayerDoneEvent).output;
217
231
 
@@ -832,7 +846,7 @@ export const reviewMachine = setup({
832
846
  done: {
833
847
  id: 'done',
834
848
  description: stateDescriptions.done,
835
- meta: playbookMeta('done'),
849
+ meta: terminalMeta('done', 'success'),
836
850
  type: 'final',
837
851
  },
838
852
  },
package/slc/gears2fsm.md CHANGED
@@ -431,8 +431,11 @@ behavior. The generic `failed` state is the default only when Source declares
431
431
  no recovery or reassessment path for a rejected child.
432
432
  That recovering `onError` shall be an ordered transition array. Its first arm
433
433
  shall use a typed structural guard that accepts only an `Error` carrying a
434
- validated public child `result` with `status: 'aborted' | 'error'`; only that
435
- arm appends sanitized child evidence and continues. A fallback arm shall retain
434
+ validated public child `result` that is either `status: 'aborted' | 'error'`
435
+ or `status: 'ok'` with `terminal.kind === 'failure'` a child that completed
436
+ at its own authored failure terminal, which the bridge delivers through this
437
+ same error path; only that arm appends sanitized child evidence and
438
+ continues. A fallback arm shall retain
436
439
  the control error normalized as JSON-safe `{ name, message, stack? }` in
437
440
  `lastError` and route to `failed` without appending a completed child result;
438
441
  the linked runtime alone retains the original error in its out-of-machine
@@ -444,9 +447,14 @@ result, the error action shall inspect whether its status was `aborted` or
444
447
  `error`; it shall not collapse both into an invented success/failure enum. The
445
448
  FSM may inspect that public structural data without importing the runner or
446
449
  constructing runtime call identities.
450
+ Because a failure terminal reaches `onError`, `onDone` alone proves the child
451
+ succeeded: a caller shall not decide success by inspecting the callee's output
452
+ fields, and shall read those fields only where its own Source relays them.
447
453
  For a workflow that reassesses child results, use a typed JSON-safe record such
448
454
  as `{ playbookId, status: 'ok', output }` on `onDone` and
449
- `{ playbookId, status: 'aborted' | 'error', error }` on `onError`. Because the
455
+ `{ playbookId, status: 'aborted' | 'error', error }` on `onError`, with a
456
+ rejected `status: 'ok'` failure terminal recorded in that same `ok` shape so
457
+ the child's own output is relayed. Because the
450
458
  runtime rejection is an `Error` with a public `result` property, normalization
451
459
  shall inspect `result.status` and `result.error` before applying a generic
452
460
  `Error` normalizer. It shall persist only the current context target id, the
@@ -773,6 +781,16 @@ This constrains only published meaning: the declared machine `output` still
773
781
  derives its status and fields from typed context, so a caller that does read
774
782
  the output is unaffected.
775
783
 
784
+ Each final state shall additionally declare `terminal: 'success' | 'failure'`
785
+ in its `meta.playbook`, derived from the Source's own outcome wording exactly
786
+ as the description is: an outcome Source states as the workflow completing is
787
+ `'success'`, and one Source states as a failure the workflow reports instead
788
+ of parking is `'failure'`. The kind is fixed at compile time and adds no agent
789
+ call. The runtime publishes it, with the reached state's id and description, as
790
+ the completed run's terminal record, so this workflow's own caller learns
791
+ whether it succeeded from the machine it reached rather than from these output
792
+ fields.
793
+
776
794
  Where Source declares a JSON-safe terminal result, the setup types shall
777
795
  declare that output and the root machine shall derive it from typed context
778
796
  through XState's machine `output` function. A final-state transition alone does
package/slc/link.md CHANGED
@@ -338,6 +338,7 @@ type PlaybookRunResult =
338
338
  outcome: 'terminal';
339
339
  state: PlaybookState;
340
340
  stateDescription?: string;
341
+ terminal?: PlaybookTerminalOutcome;
341
342
  output?: JsonValue;
342
343
  }
343
344
  | {
@@ -515,6 +516,12 @@ interface PlaybookCallRequest {
515
516
  text: string;
516
517
  }
517
518
 
519
+ interface PlaybookTerminalOutcome {
520
+ stateId: string;
521
+ kind: 'success' | 'failure';
522
+ description?: string;
523
+ }
524
+
518
525
  type PlaybookCallResult =
519
526
  | {
520
527
  status: 'ok';
@@ -522,6 +529,7 @@ type PlaybookCallResult =
522
529
  childSessionId: string;
523
530
  state?: PlaybookState;
524
531
  output?: JsonValue;
532
+ terminal?: PlaybookTerminalOutcome;
525
533
  }
526
534
  | {
527
535
  status: 'aborted';
@@ -1448,12 +1456,23 @@ playbook id, and child session id; bind its new turn signal for work resumed in
1448
1456
  the parent; emit and drain the call-finish trace; settle the bridge deferred;
1449
1457
  and use XState `waitFor` to drive the parent to its next
1450
1458
  quiescent, suspended, failed, aborted, or terminal result.
1451
- An `ok` result resolves the actor and reaches `invoke.onDone`; `aborted` and
1452
- `error` results reject it and reach `invoke.onError`.
1459
+ An `ok` result whose `terminal.kind` is `success`, and an `ok` result carrying
1460
+ no `terminal` record at all, resolve the actor and reach `invoke.onDone`;
1461
+ `aborted` and `error` results, and an `ok` result whose `terminal.kind` is
1462
+ `failure`, reject it and reach `invoke.onError`.
1453
1463
  The rejection shall be an `Error` whose public readonly `result` property is
1454
1464
  the exact normalized `PlaybookCallResult`; throwing the result object directly
1455
1465
  or discarding its status prevents the FSM from distinguishing abort from
1456
1466
  failure during recovery.
1467
+ A completed child's `ok` result carries `terminal` exactly when the child's
1468
+ artifact declares its reached final state's kind: the runtime reads
1469
+ `meta.playbook.terminal` and that state's authored description from the
1470
+ artifact, never from an agent reply, and a declared value other than `success`
1471
+ or `failure` is a control-plane error rather than a child outcome.
1472
+ Because the bridge routes a failure terminal to the error path, `onDone` alone
1473
+ proves the child succeeded and a caller never inspects a callee's output fields
1474
+ to decide that; a caller reads those fields only when its own Source relays
1475
+ them.
1457
1476
  Unknown, duplicate, or stale call ids reject without changing actor state.
1458
1477
  The finish trace shall therefore precede any parent FSM transition caused by
1459
1478
  the child return.
package/src/runtime.d.ts CHANGED
@@ -62,12 +62,18 @@ export interface PlaybookCallRequest {
62
62
  playbookId: string;
63
63
  text: string;
64
64
  }
65
+ export interface PlaybookTerminalOutcome {
66
+ stateId: string;
67
+ kind: 'success' | 'failure';
68
+ description?: string;
69
+ }
65
70
  export type PlaybookCallResult = {
66
71
  status: 'ok';
67
72
  playbookId: string;
68
73
  childSessionId: string;
69
74
  state?: PlaybookState;
70
75
  output?: JsonValue;
76
+ terminal?: PlaybookTerminalOutcome;
71
77
  } | {
72
78
  status: 'aborted';
73
79
  playbookId: string;
@@ -102,6 +108,7 @@ export type PlaybookRunResult = {
102
108
  outcome: 'terminal';
103
109
  state: PlaybookState;
104
110
  stateDescription?: string;
111
+ terminal?: PlaybookTerminalOutcome;
105
112
  output?: JsonValue;
106
113
  } | {
107
114
  outcome: 'suspended';
package/src/runtime.ts CHANGED
@@ -98,6 +98,16 @@ export interface PlaybookCallRequest {
98
98
  text: string;
99
99
  }
100
100
 
101
+ // DR-048: the reached final state's compiled terminal meaning. `kind` is
102
+ // authored metadata read from the artifact, never from an agent reply, and
103
+ // `description` repeats that state's authored description when it declares
104
+ // one. An artifact whose final states declare no kind carries no such record.
105
+ export interface PlaybookTerminalOutcome {
106
+ stateId: string;
107
+ kind: 'success' | 'failure';
108
+ description?: string;
109
+ }
110
+
101
111
  export type PlaybookCallResult =
102
112
  | {
103
113
  status: 'ok';
@@ -105,6 +115,7 @@ export type PlaybookCallResult =
105
115
  childSessionId: string;
106
116
  state?: PlaybookState;
107
117
  output?: JsonValue;
118
+ terminal?: PlaybookTerminalOutcome;
108
119
  }
109
120
  | {
110
121
  status: 'aborted';
@@ -137,6 +148,7 @@ export type PlaybookRunResult =
137
148
  outcome: 'terminal';
138
149
  state: PlaybookState;
139
150
  stateDescription?: string;
151
+ terminal?: PlaybookTerminalOutcome;
140
152
  output?: JsonValue;
141
153
  }
142
154
  | {
@@ -468,6 +468,19 @@ export declare function resumableStateIdsFromMachine(machine: AnyStateMachine):
468
468
  * these descriptions (DR-029); a state without one has no entry.
469
469
  */
470
470
  export declare function stateDescriptionsFromMachine(machine: AnyStateMachine): ReadonlyMap<string, string>;
471
+ /**
472
+ * DR-048: each root final state's declared terminal kind, read from
473
+ * `meta.playbook.terminal` in `machine.config`. The kind is compiled
474
+ * metadata — the compiler derives it from the Source's own outcome wording,
475
+ * exactly as it derives the state's description — so a caller learns whether
476
+ * a completed child succeeded from the machine it reached, never from the
477
+ * child's output fields or an agent's prose.
478
+ *
479
+ * A machine whose final states declare no kind yields an empty map and keeps
480
+ * the pre-DR-048 delivery. A `terminal` on a non-final state, or a value
481
+ * other than `success` or `failure`, is a malformed artifact and throws.
482
+ */
483
+ export declare function terminalOutcomesFromMachine(machine: AnyStateMachine, label?: string): ReadonlyMap<string, 'success' | 'failure'>;
471
484
  /**
472
485
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
473
486
  * under the slc/link.md contract. The factory provides every actor kind the