@sublang/playbook 12.2.2 → 12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "12.2.2",
3
+ "version": "12.3.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, and DEV workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,10 @@ import {
28
28
  stringify as stringifyYaml,
29
29
  } from "yaml";
30
30
  import { loadTmuxPlayConfig } from "@sublang/cligent/tmux-play";
31
- import { defaultCaptainSessionsDir } from "./session-store.js";
31
+ import {
32
+ defaultCaptainSessionsDir,
33
+ isCanonicalLocalRoleId,
34
+ } from "./session-store.js";
32
35
 
33
36
  const here = dirname(fileURLToPath(import.meta.url));
34
37
  const DEFAULT_TEMPLATE_PATH = resolve(
@@ -53,7 +56,6 @@ const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
53
56
  const RESERVED_CAPTAIN_PLAYBOOK_ID = "captain";
54
57
  const RESERVED_CAPTAIN_ROLE_ID = "captain";
55
58
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
56
- const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
57
59
 
58
60
  // PBCLI-26: split ordered `--with <path>` pairs out of an argument vector.
59
61
  // The returned arrays are new values; the caller's vector is never changed.
@@ -2060,8 +2062,11 @@ function assertPlayerId(value, path) {
2060
2062
  }
2061
2063
 
2062
2064
  function assertRoleId(value, path) {
2063
- if (typeof value !== "string" || !ROLE_ID_PATTERN.test(value)) {
2064
- throw new Error(`${path} must use a canonical lowercase local role id`);
2065
+ if (!isCanonicalLocalRoleId(value)) {
2066
+ throw new Error(
2067
+ `${path} must be a canonical lowercase local role id ` +
2068
+ "(the role name lowercased, no whitespace)",
2069
+ );
2065
2070
  }
2066
2071
  if (value === RESERVED_CAPTAIN_ROLE_ID) {
2067
2072
  throw new Error(`${path} uses reserved local role id "captain"`);
@@ -2077,7 +2082,7 @@ function invalidManifestRoles(value) {
2077
2082
  if (new Set(canonical).size !== canonical.length) {
2078
2083
  return "contains roles that collide after canonical lowercase derivation";
2079
2084
  }
2080
- const invalid = value.find((role) => !ROLE_ID_PATTERN.test(role));
2085
+ const invalid = value.find((role) => !isCanonicalLocalRoleId(role));
2081
2086
  if (invalid !== undefined) {
2082
2087
  return `contains noncanonical role ${JSON.stringify(invalid)}`;
2083
2088
  }
@@ -2099,8 +2104,7 @@ function invalidConcurrentRoleSets(value, requiredRoleIds) {
2099
2104
  if (
2100
2105
  set.some(
2101
2106
  (role) =>
2102
- typeof role !== "string" ||
2103
- !ROLE_ID_PATTERN.test(role) ||
2107
+ !isCanonicalLocalRoleId(role) ||
2104
2108
  role === RESERVED_CAPTAIN_ROLE_ID ||
2105
2109
  !required.has(role),
2106
2110
  )
@@ -57,8 +57,22 @@ export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
57
57
  export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
58
58
 
59
59
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
60
- const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
60
+ const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
61
61
  const RESERVED_ID = 'captain';
62
+
63
+ // PBCLI-4: a local role id is its source role name lowercased by Unicode case
64
+ // mapping — nonempty, free of whitespace and control characters, and equal to
65
+ // its own lowercase form. The rule restricts neither script nor alphabet, so
66
+ // `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
67
+ // enforce the reserved `captain` name separately.
68
+ export function isCanonicalLocalRoleId(value) {
69
+ return (
70
+ typeof value === 'string' &&
71
+ value.length > 0 &&
72
+ value === value.toLowerCase() &&
73
+ !ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
74
+ );
75
+ }
62
76
  const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
63
77
  const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
64
78
 
@@ -5080,10 +5094,7 @@ function validateRoleIds(value, path) {
5080
5094
  if (
5081
5095
  !Array.isArray(value) ||
5082
5096
  value.some(
5083
- (roleId) =>
5084
- typeof roleId !== 'string' ||
5085
- !ROLE_ID_PATTERN.test(roleId) ||
5086
- roleId === RESERVED_ID,
5097
+ (roleId) => !isCanonicalLocalRoleId(roleId) || roleId === RESERVED_ID,
5087
5098
  ) ||
5088
5099
  new Set(value).size !== value.length
5089
5100
  ) {
@@ -77,6 +77,18 @@ function playbookMeta(stateId, role) {
77
77
  },
78
78
  };
79
79
  }
80
+ // DR-048: a final state additionally declares whether its outcome means the
81
+ // workflow succeeded or failed, so a caller learns that from the machine
82
+ // rather than from CODE's output fields.
83
+ function terminalMeta(stateId, terminal) {
84
+ return {
85
+ playbook: {
86
+ stateId,
87
+ description: STATE_DESCRIPTIONS[stateId],
88
+ terminal,
89
+ },
90
+ };
91
+ }
80
92
  function isRecord(value) {
81
93
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
82
94
  return false;
@@ -206,6 +218,16 @@ function nestedResultFromError(error) {
206
218
  const result = error.result;
207
219
  return isRecord(result) ? result : undefined;
208
220
  }
221
+ function isFailureTerminalRecord(value) {
222
+ if (!isRecord(value))
223
+ return false;
224
+ const allowed = new Set(['stateId', 'kind', 'description']);
225
+ return (Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key)) &&
226
+ isNonEmptyString(value.stateId) &&
227
+ value.kind === 'failure' &&
228
+ (!Object.prototype.hasOwnProperty.call(value, 'description') ||
229
+ typeof value.description === 'string'));
230
+ }
209
231
  function normalizedReviewFailure(error) {
210
232
  const result = nestedResultFromError(error);
211
233
  if (result === undefined)
@@ -215,10 +237,12 @@ function normalizedReviewFailure(error) {
215
237
  'playbookId',
216
238
  'childSessionId',
217
239
  'state',
218
- 'error',
240
+ ...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
219
241
  ]);
220
242
  if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.has(key)) ||
221
- (result.status !== 'aborted' && result.status !== 'error') ||
243
+ (result.status !== 'aborted' &&
244
+ result.status !== 'error' &&
245
+ result.status !== 'ok') ||
222
246
  result.playbookId !== 'review') {
223
247
  return undefined;
224
248
  }
@@ -230,6 +254,19 @@ function normalizedReviewFailure(error) {
230
254
  !isPlaybookState(result.state)) {
231
255
  return undefined;
232
256
  }
257
+ if (result.status === 'ok') {
258
+ if (!Object.prototype.hasOwnProperty.call(result, 'terminal') ||
259
+ !isFailureTerminalRecord(result.terminal) ||
260
+ !isNonEmptyString(result.childSessionId)) {
261
+ return undefined;
262
+ }
263
+ return {
264
+ status: 'ok',
265
+ ...(result.output === undefined
266
+ ? {}
267
+ : { output: result.output }),
268
+ };
269
+ }
233
270
  let normalizedError;
234
271
  if (Object.prototype.hasOwnProperty.call(result, 'error')) {
235
272
  if (!isRecord(result.error))
@@ -274,8 +311,9 @@ function compactError(value) {
274
311
  function authoredReviewError(event) {
275
312
  const outer = isRecord(event) ? event.error : undefined;
276
313
  const failure = normalizedReviewFailure(outer);
277
- if (failure?.error !== undefined)
314
+ if (failure !== undefined && failure.status !== 'ok' && failure.error) {
278
315
  return failure.error;
316
+ }
279
317
  if (failure?.status === 'aborted') {
280
318
  return { name: 'AbortError', message: 'REVIEW was aborted.' };
281
319
  }
@@ -787,13 +825,13 @@ export const codingMachine = machineSetup.createMachine({
787
825
  reportedReviewFailure: {
788
826
  id: 'reportedReviewFailure',
789
827
  description: STATE_DESCRIPTIONS.reportedReviewFailure,
790
- meta: playbookMeta('reportedReviewFailure'),
828
+ meta: terminalMeta('reportedReviewFailure', 'failure'),
791
829
  type: 'final',
792
830
  },
793
831
  done: {
794
832
  id: 'done',
795
833
  description: STATE_DESCRIPTIONS.done,
796
- meta: playbookMeta('done'),
834
+ meta: terminalMeta('done', 'success'),
797
835
  type: 'final',
798
836
  },
799
837
  },
@@ -241,6 +241,22 @@ function playbookMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
241
241
  };
242
242
  }
243
243
 
244
+ // DR-048: a final state additionally declares whether its outcome means the
245
+ // workflow succeeded or failed, so a caller learns that from the machine
246
+ // rather than from CODE's output fields.
247
+ function terminalMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
248
+ stateId: StateId,
249
+ terminal: 'success' | 'failure',
250
+ ) {
251
+ return {
252
+ playbook: {
253
+ stateId,
254
+ description: STATE_DESCRIPTIONS[stateId],
255
+ terminal,
256
+ },
257
+ };
258
+ }
259
+
244
260
  function isRecord(value: unknown): value is Record<string, unknown> {
245
261
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
246
262
  return false;
@@ -419,10 +435,32 @@ function nestedResultFromError(error: unknown): Record<string, unknown> | undefi
419
435
  return isRecord(result) ? result : undefined;
420
436
  }
421
437
 
422
- type AuthoredReviewFailure = {
423
- readonly status: 'aborted' | 'error';
424
- readonly error?: CompactError;
425
- };
438
+ type AuthoredReviewFailure =
439
+ | {
440
+ readonly status: 'aborted' | 'error';
441
+ readonly error?: CompactError;
442
+ }
443
+ // DR-048: a child that completed at an authored failure terminal. The
444
+ // bridge rejects it through the same error path, so CODE recognizes the
445
+ // failure from REVIEW's own machine and never from its output fields.
446
+ | {
447
+ readonly status: 'ok';
448
+ readonly output?: JsonValue;
449
+ };
450
+
451
+ function isFailureTerminalRecord(value: unknown): boolean {
452
+ if (!isRecord(value)) return false;
453
+ const allowed = new Set(['stateId', 'kind', 'description']);
454
+ return (
455
+ Reflect.ownKeys(value).every(
456
+ (key) => typeof key === 'string' && allowed.has(key),
457
+ ) &&
458
+ isNonEmptyString(value.stateId) &&
459
+ value.kind === 'failure' &&
460
+ (!Object.prototype.hasOwnProperty.call(value, 'description') ||
461
+ typeof value.description === 'string')
462
+ );
463
+ }
426
464
 
427
465
  function normalizedReviewFailure(
428
466
  error: unknown,
@@ -434,13 +472,15 @@ function normalizedReviewFailure(
434
472
  'playbookId',
435
473
  'childSessionId',
436
474
  'state',
437
- 'error',
475
+ ...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
438
476
  ]);
439
477
  if (
440
478
  Reflect.ownKeys(result).some(
441
479
  (key) => typeof key !== 'string' || !allowed.has(key),
442
480
  ) ||
443
- (result.status !== 'aborted' && result.status !== 'error') ||
481
+ (result.status !== 'aborted' &&
482
+ result.status !== 'error' &&
483
+ result.status !== 'ok') ||
444
484
  result.playbookId !== 'review'
445
485
  ) {
446
486
  return undefined;
@@ -457,6 +497,21 @@ function normalizedReviewFailure(
457
497
  ) {
458
498
  return undefined;
459
499
  }
500
+ if (result.status === 'ok') {
501
+ if (
502
+ !Object.prototype.hasOwnProperty.call(result, 'terminal') ||
503
+ !isFailureTerminalRecord(result.terminal) ||
504
+ !isNonEmptyString(result.childSessionId)
505
+ ) {
506
+ return undefined;
507
+ }
508
+ return {
509
+ status: 'ok',
510
+ ...(result.output === undefined
511
+ ? {}
512
+ : { output: result.output as JsonValue }),
513
+ };
514
+ }
460
515
  let normalizedError: CompactError | undefined;
461
516
  if (Object.prototype.hasOwnProperty.call(result, 'error')) {
462
517
  if (!isRecord(result.error)) return undefined;
@@ -507,7 +562,9 @@ function compactError(value: unknown): CompactError {
507
562
  function authoredReviewError(event: unknown): CompactError {
508
563
  const outer = isRecord(event) ? event.error : undefined;
509
564
  const failure = normalizedReviewFailure(outer);
510
- if (failure?.error !== undefined) return failure.error;
565
+ if (failure !== undefined && failure.status !== 'ok' && failure.error) {
566
+ return failure.error;
567
+ }
511
568
  if (failure?.status === 'aborted') {
512
569
  return { name: 'AbortError', message: 'REVIEW was aborted.' };
513
570
  }
@@ -1052,13 +1109,13 @@ export const codingMachine = machineSetup.createMachine({
1052
1109
  reportedReviewFailure: {
1053
1110
  id: 'reportedReviewFailure',
1054
1111
  description: STATE_DESCRIPTIONS.reportedReviewFailure,
1055
- meta: playbookMeta('reportedReviewFailure'),
1112
+ meta: terminalMeta('reportedReviewFailure', 'failure'),
1056
1113
  type: 'final',
1057
1114
  },
1058
1115
  done: {
1059
1116
  id: 'done',
1060
1117
  description: STATE_DESCRIPTIONS.done,
1061
- meta: playbookMeta('done'),
1118
+ meta: terminalMeta('done', 'success'),
1062
1119
  type: 'final',
1063
1120
  },
1064
1121
  },
@@ -60,8 +60,21 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
60
60
  const INTERNAL_CAPTAIN_ID = 'captain';
61
61
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
62
62
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
63
- const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
63
+ const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
64
64
  const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
65
+ // PBCLI-4: a local role id is its source role name lowercased by Unicode case
66
+ // mapping — nonempty, free of whitespace and control characters, and equal to
67
+ // its own lowercase form. The rule restricts neither script nor alphabet, so
68
+ // `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
69
+ // enforce the reserved `captain` name separately. The CLI host owns the same
70
+ // predicate in `bin/session-store.js`; that private module already imports
71
+ // this one, so the text is repeated here rather than cycled back.
72
+ function isCanonicalLocalRoleId(value) {
73
+ return (typeof value === 'string' &&
74
+ value.length > 0 &&
75
+ value === value.toLowerCase() &&
76
+ !ROLE_ID_WHITESPACE_OR_CONTROL.test(value));
77
+ }
65
78
  const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID = 'reconcile:unresolved-effect';
66
79
  const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
67
80
  // The fixed machine-syntax guard of a player's Boss-question suspension
@@ -685,9 +698,7 @@ function isValidRegistryEntry(value, artifactSchema) {
685
698
  return false;
686
699
  const e = value;
687
700
  if (!Array.isArray(e.requiredRoleIds) ||
688
- e.requiredRoleIds.some((role) => typeof role !== 'string' ||
689
- !ROLE_ID_PATTERN.test(role) ||
690
- role === INTERNAL_CAPTAIN_ID) ||
701
+ e.requiredRoleIds.some((role) => !isCanonicalLocalRoleId(role) || role === INTERNAL_CAPTAIN_ID) ||
691
702
  new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
692
703
  !Array.isArray(e.concurrentRoleSets)) {
693
704
  return false;
@@ -911,7 +922,7 @@ function snapshotPlayerSessions(value, path) {
911
922
  function snapshotFrameRoleBindings(value, path) {
912
923
  const bindings = snapshotRecord(value, path);
913
924
  return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
914
- if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
925
+ if (!isCanonicalLocalRoleId(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
915
926
  throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
916
927
  }
917
928
  const playerId = snapshotString(raw, `${path}.${roleId}`);
@@ -3305,6 +3316,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3305
3316
  childSessionId: frame.sessionId,
3306
3317
  state: result.state,
3307
3318
  ...(result.output !== undefined ? { output: result.output } : {}),
3319
+ // DR-048: the child runtime read this record from its own artifact;
3320
+ // the host relays it unchanged so the caller's bridge can route a
3321
+ // failure terminal without knowing the callee's output fields.
3322
+ ...(result.terminal !== undefined
3323
+ ? { terminal: result.terminal }
3324
+ : {}),
3308
3325
  };
3309
3326
  }
3310
3327
  if (result.outcome === 'aborted') {
@@ -549,8 +549,24 @@ const INTERNAL_CAPTAIN_ID = 'captain';
549
549
  const UUID_PATTERN =
550
550
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
551
551
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
552
- const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
552
+ const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
553
553
  const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
554
+
555
+ // PBCLI-4: a local role id is its source role name lowercased by Unicode case
556
+ // mapping — nonempty, free of whitespace and control characters, and equal to
557
+ // its own lowercase form. The rule restricts neither script nor alphabet, so
558
+ // `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
559
+ // enforce the reserved `captain` name separately. The CLI host owns the same
560
+ // predicate in `bin/session-store.js`; that private module already imports
561
+ // this one, so the text is repeated here rather than cycled back.
562
+ function isCanonicalLocalRoleId(value: unknown): value is string {
563
+ return (
564
+ typeof value === 'string' &&
565
+ value.length > 0 &&
566
+ value === value.toLowerCase() &&
567
+ !ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
568
+ );
569
+ }
554
570
  const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
555
571
  'reconcile:unresolved-effect';
556
572
  const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
@@ -1452,9 +1468,7 @@ function isValidRegistryEntry(
1452
1468
  !Array.isArray(e.requiredRoleIds) ||
1453
1469
  e.requiredRoleIds.some(
1454
1470
  (role) =>
1455
- typeof role !== 'string' ||
1456
- !ROLE_ID_PATTERN.test(role) ||
1457
- role === INTERNAL_CAPTAIN_ID,
1471
+ !isCanonicalLocalRoleId(role) || role === INTERNAL_CAPTAIN_ID,
1458
1472
  ) ||
1459
1473
  new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
1460
1474
  !Array.isArray(e.concurrentRoleSets)
@@ -1773,7 +1787,7 @@ function snapshotFrameRoleBindings(
1773
1787
  const bindings = snapshotRecord(value, path);
1774
1788
  return Object.fromEntries(
1775
1789
  Object.entries(bindings).map(([roleId, raw]) => {
1776
- if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
1790
+ if (!isCanonicalLocalRoleId(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
1777
1791
  throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
1778
1792
  }
1779
1793
  const playerId = snapshotString(raw, `${path}.${roleId}`);
@@ -5177,6 +5191,12 @@ export function createPlaybookCaptainShell(
5177
5191
  childSessionId: frame.sessionId,
5178
5192
  state: result.state,
5179
5193
  ...(result.output !== undefined ? { output: result.output } : {}),
5194
+ // DR-048: the child runtime read this record from its own artifact;
5195
+ // the host relays it unchanged so the caller's bridge can route a
5196
+ // failure terminal without knowing the callee's output fields.
5197
+ ...(result.terminal !== undefined
5198
+ ? { terminal: result.terminal }
5199
+ : {}),
5180
5200
  };
5181
5201
  }
5182
5202
  if (result.outcome === 'aborted') {
@@ -960,6 +960,9 @@ export const decideMachine = setup({
960
960
  playbook: {
961
961
  stateId: 'reportedReviewFailure',
962
962
  description: 'DECIDE reports REVIEW’s failure and its last commit.',
963
+ // DR-048: this final state means the workflow failed, so a caller
964
+ // learns that from the machine rather than from DECIDE's output.
965
+ terminal: 'failure',
963
966
  },
964
967
  },
965
968
  },
@@ -971,6 +974,7 @@ export const decideMachine = setup({
971
974
  playbook: {
972
975
  stateId: 'done',
973
976
  description: 'DECIDE completed with an approved commit.',
977
+ terminal: 'success',
974
978
  },
975
979
  },
976
980
  },
@@ -1233,6 +1233,9 @@ export const decideMachine = setup({
1233
1233
  playbook: {
1234
1234
  stateId: 'reportedReviewFailure',
1235
1235
  description: 'DECIDE reports REVIEW’s failure and its last commit.',
1236
+ // DR-048: this final state means the workflow failed, so a caller
1237
+ // learns that from the machine rather than from DECIDE's output.
1238
+ terminal: 'failure',
1236
1239
  },
1237
1240
  },
1238
1241
  },
@@ -1244,6 +1247,7 @@ export const decideMachine = setup({
1244
1247
  playbook: {
1245
1248
  stateId: 'done',
1246
1249
  description: 'DECIDE completed with an approved commit.',
1250
+ terminal: 'success',
1247
1251
  },
1248
1252
  },
1249
1253
  },
@@ -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
  }