@sublang/playbook 12.2.1 → 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.1",
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
  )
@@ -1952,9 +1952,9 @@ async function releaseRepositoryClaim(claim, primaryError) {
1952
1952
  }
1953
1953
  }
1954
1954
 
1955
- // A host binds the governed worktree identity for each durable call — fixed
1956
- // at construction for the Captain host, resolved afresh from `cwd` for the
1957
- // worktree host capability — and derives the schema-3 authority naming it.
1955
+ // A host binds the governed worktree identity for each durable call —
1956
+ // resolved afresh from `cwd` by both the Captain host and the worktree host
1957
+ // capability — and derives the schema-3 authority naming it.
1958
1958
  function bindHostWorktree(host) {
1959
1959
  return host.resolveIdentity().then((identity) => ({
1960
1960
  identity,
@@ -2978,33 +2978,43 @@ export async function createRepositoryEffectCapabilities({
2978
2978
  await sessionLease.assertOwner();
2979
2979
  const createdWriteAhead = await createWriteAhead(sessionLease);
2980
2980
  const ledgerService = assertEffectLedgerService(createdWriteAhead);
2981
- const identity = await resolveCanonicalGitWorktree(cwd);
2981
+ // The governed worktree is bound afresh from `cwd` at every governed call
2982
+ // and observation, so a working directory that is not a repository yet is
2983
+ // governed from launch as its own prospective root and the `git init` a
2984
+ // workflow's own setup step runs there keeps that one identity.
2985
+ const resolveIdentity = async () => (await resolveGovernedWorktree(cwd)).identity;
2986
+ const identity = await resolveIdentity();
2982
2987
  await sessionLease.assertOwner();
2983
2988
  const coordinator = createRepositoryEffectCoordinator();
2984
2989
 
2985
2990
  const capabilities = schema3Entries.map(
2986
2991
  ({ playbookId, requiredRoleIds, concurrentRoleSets }) => {
2987
- const authority = deepFreeze({
2988
- playbookId,
2989
- artifactSchema: 3,
2990
- cwd,
2991
- sessionId,
2992
- leaseOwnerToken: sessionLease.ownerToken,
2993
- canonicalWorktree: identity,
2994
- requiredRoleIds,
2995
- concurrentRoleSets,
2996
- });
2992
+ const authorityNaming = (canonicalWorktree) =>
2993
+ deepFreeze({
2994
+ playbookId,
2995
+ artifactSchema: 3,
2996
+ cwd,
2997
+ sessionId,
2998
+ leaseOwnerToken: sessionLease.ownerToken,
2999
+ canonicalWorktree,
3000
+ requiredRoleIds,
3001
+ concurrentRoleSets,
3002
+ });
3003
+ const authority = authorityNaming(identity);
2997
3004
  const host = Object.freeze({
2998
- resolveIdentity: async () => identity,
2999
- authorityFor: () => authority,
3005
+ resolveIdentity,
3006
+ authorityFor: (canonicalWorktree) =>
3007
+ isDeepStrictEqual(canonicalWorktree, identity)
3008
+ ? authority
3009
+ : authorityNaming(canonicalWorktree),
3000
3010
  });
3001
3011
  const observe = async (options = {}) => {
3002
3012
  rejectBoundRepositoryOverride(options, 'observe', ['cwd']);
3003
- return observeResolvedWorktree(identity, options);
3013
+ return observeGovernedWorktree(await resolveGovernedWorktree(cwd), options);
3004
3014
  };
3005
3015
  const acquire = async (options = {}) => {
3006
3016
  rejectBoundRepositoryOverride(options, 'acquire', ['cwd']);
3007
- return coordinator.acquire(identity.worktree, options);
3017
+ return coordinator.acquire(cwd, options);
3008
3018
  };
3009
3019
  const runExclusive = async (options) => {
3010
3020
  return runDurableExclusive({
@@ -3139,11 +3149,11 @@ async function assertExistingDirectory(cwd, label) {
3139
3149
  // DR-046: the lease-free worktree capability an embedding host constructs
3140
3150
  // through `@sublang/playbook/host-capabilities`. It runs the same claim,
3141
3151
  // observation, receipt, completion, and deferred-operation path as the
3142
- // Captain-hosted capability above, over one host-owned in-memory ledger.
3143
- // Unlike that capability, it binds its worktree lazily: `cwd` need not be a
3144
- // repository yet, and every governed call and observation re-resolves the
3145
- // governed worktree of `cwd`, so a `git init` there — inside a governed call
3146
- // or between calls — is observed rather than hidden behind a stale identity.
3152
+ // Captain-hosted capability above, over one host-owned in-memory ledger, and
3153
+ // binds its worktree the same lazy way: `cwd` need not be a repository yet,
3154
+ // and every governed call and observation re-resolves the governed worktree
3155
+ // of `cwd`, so a `git init` there — inside a governed call or between calls —
3156
+ // is observed rather than hidden behind a stale identity.
3147
3157
  export async function createWorktreeHostCapabilities(options = {}) {
3148
3158
  if (!isPlainObject(options)) {
3149
3159
  throw new TypeError('worktree host capability options must be an object');
@@ -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
  }