@sublang/playbook 1.3.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, and DISCUSS workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -126,7 +126,7 @@
126
126
  "dependencies": {
127
127
  "@anthropic-ai/claude-agent-sdk": "^0.3.154",
128
128
  "@openai/codex-sdk": "^0.139.0",
129
- "@sublang/cligent": "^0.15.0",
129
+ "@sublang/cligent": "^0.16.0",
130
130
  "@sublang/spex": "^0.3.0",
131
131
  "p-queue": "^9.3.1",
132
132
  "xstate": "^5.19.4",
package/slc/link.md CHANGED
@@ -257,15 +257,21 @@ array requests a tool-free call, while omission preserves the host Captain's
257
257
  configured tools.
258
258
  `CaptainResult` carries no resume token or player-continuation selection.
259
259
  A non-`ok`
260
- result, or an `ok` result without `finalText`, shall reject the actor through
261
- the FSM's error path.
262
- Outside a signal-driven abort, those invalid direct-Captain results are
263
- latched control-plane failures. The runtime shall let the actor take `onError`,
264
- drive it to quiescence, and drain ordered emissions before the public method
265
- rejects with the original failure. It shall never translate either case into
266
- a recoverable workflow `{ outcome: 'failed' }` result. If the combined signal
267
- has aborted, an aborted host result follows the ordinary abort settlement
268
- instead of being promoted to a control-plane failure.
260
+ result, or an `ok` result without non-empty `finalText`, shall record that
261
+ failure on the call's single finish trace and reject the actor through the
262
+ FSM's error path. These structured host-result failures are recoverable
263
+ workflow failures, not control-plane failures: the runtime shall let the actor
264
+ take `onError`, drive it to quiescence, drain ordered emissions, and resolve
265
+ the public method with `{ outcome: 'failed' }` carrying the failure state's
266
+ error. This matches the delegated-player result boundary.
267
+ A non-abort thrown `callCaptain` port, a malformed host result, and a rejecting
268
+ trace sink remain control-plane failures that reject the public method. If the
269
+ required finish sink rejects after a structured host-result failure, the
270
+ actor's error and failure-state evidence shall remain the host-result failure,
271
+ while the public method rejects with the sink failure surfaced by the turn's
272
+ emission drain. Absent such a control-plane failure, if the combined signal
273
+ has aborted, ordinary abort settlement remains authoritative after the actor
274
+ reaches its error path.
269
275
 
270
276
  Every linked runtime owns a map from resolved player id to its latest non-empty `resumeToken`.
271
277
  Before reading a resolved direct-Captain or delegated-player result, the
@@ -753,8 +759,11 @@ Adjudicator failures are control-plane errors.
753
759
  The runtime shall propagate them by throwing out of `handleBossInput` after attempting cleanup.
754
760
  The host adapter surfaces the throw on its control-plane channel (cligent surfaces such throws as `runtime_error` per [TMUX-025](https://github.com/sublang-ai/cligent/blob/main/specs/user/tmux-play.md#tmux-025)).
755
761
  The host's player-result channels (`player_finished` and equivalents) are reserved for failures the player itself produced; the host emits them when `callPlayer` resolves with `status !== 'ok'`.
756
- Captain call failures stay on the Captain/control boundary and shall not be
757
- reported as player failures.
762
+ Direct-Captain host-result failures stay on the Captain actor boundary and
763
+ shall not be reported as player failures; they follow the recoverable FSM
764
+ failure path specified above. Captain transport, result-shape, trace-sink, and
765
+ adjudication failures remain control-plane errors unless the transport failure
766
+ is causally identical to the active abort signal.
758
767
  Because XState still needs the invoked promise to settle, the linked runtime
759
768
  shall latch an adjudicator, actor-output JSON-validation, or nested-boundary
760
769
  control error outside machine context, allow the invocation's `onError` path to
@@ -1316,6 +1325,13 @@ The emitted module:
1316
1325
  weaken, runtime-derived entry text ownership or closed interrupt targets.
1317
1326
  A conflicting duplicate field contract is a linker/runtime construction
1318
1327
  error.
1328
+ `NO_ACTION` and `BOSS_REPLY` are runtime-owned event types the factory
1329
+ supplies itself — `NO_ACTION` as exactly `{ type: 'NO_ACTION' }`, and
1330
+ `BOSS_REPLY` as an optional judge-selected `questionId` plus the exact-text
1331
+ `answer` the runtime attaches. `bossEvents` shall carry no entry for either
1332
+ type; supplying one is a construction error, so a linker that judges a
1333
+ runtime-owned arm to have lost payload detail under erasure shall report
1334
+ that gap rather than emit the entry.
1319
1335
  - Default-exports the factory call as `createPlaybookRuntime`, typed
1320
1336
  `PlaybookRuntimeFactory<PlaybookRuntimeOptions>`.
1321
1337
  - Exposes, under an `_internal` export, the pure helpers verification
@@ -23,6 +23,25 @@ export const BOSS_REPLY_ERRORS = {
23
23
  unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
24
24
  };
25
25
  // ---------------------------------------------------------------------------
26
+ // A host agent result that is not `ok` (or is `ok` with no final text) is a
27
+ // recoverable FSM failure, not a control-plane error: it travels the invoked
28
+ // actor's XState error path to the failure state and the public boundary
29
+ // resolves `failed` (PBRT-47, matching the player boundary's PBRT-9). The
30
+ // direct-Captain boundary has to emit its paired finish trace before
31
+ // rethrowing, so it needs to tell that failure apart from the control-plane
32
+ // errors it does latch — a thrown port, a malformed result, a rejecting sink.
33
+ // ---------------------------------------------------------------------------
34
+ const fsmResultFailures = new WeakSet();
35
+ function markFsmResultFailure(error) {
36
+ fsmResultFailures.add(error);
37
+ return error;
38
+ }
39
+ function isFsmResultFailure(error) {
40
+ return (typeof error === 'object' &&
41
+ error !== null &&
42
+ fsmResultFailures.has(error));
43
+ }
44
+ // ---------------------------------------------------------------------------
26
45
  // Tolerant judge-JSON recovery (slc/link.md §Boss-event mapping).
27
46
  // ---------------------------------------------------------------------------
28
47
  function isPlainObject(value) {
@@ -587,10 +606,20 @@ function configuredEventTypesForState(machine, stateId) {
587
606
  }
588
607
  return configured;
589
608
  }
609
+ // Derived contracts merge into whatever the machine already yielded for the
610
+ // same type, so a deterministic entry event that shares a type with another
611
+ // derived contract keeps its exact-text ownership instead of being replaced.
612
+ function mergeDerivedContract(contracts, contract) {
613
+ const existing = contracts.get(contract.type);
614
+ contracts.set(contract.type, {
615
+ type: contract.type,
616
+ fields: { ...(existing?.fields ?? {}), ...(contract.fields ?? {}) },
617
+ });
618
+ }
590
619
  function defaultBossEventSpecs(machine, entryEvent, supplied) {
591
620
  const contracts = new Map();
592
621
  if (entryEvent !== undefined) {
593
- contracts.set(entryEvent.type, {
622
+ mergeDerivedContract(contracts, {
594
623
  type: entryEvent.type,
595
624
  fields: { [entryEvent.textField]: { source: 'text', required: true } },
596
625
  });
@@ -601,7 +630,7 @@ function defaultBossEventSpecs(machine, entryEvent, supplied) {
601
630
  : undefined;
602
631
  const interruptTargets = rootInterrupt === undefined ? [] : transitionTargets(rootInterrupt);
603
632
  if (interruptTargets.length > 0) {
604
- contracts.set('BOSS_INTERRUPT', {
633
+ mergeDerivedContract(contracts, {
605
634
  type: 'BOSS_INTERRUPT',
606
635
  fields: {
607
636
  targetId: {
@@ -609,6 +638,10 @@ function defaultBossEventSpecs(machine, entryEvent, supplied) {
609
638
  required: true,
610
639
  values: [...new Set(interruptTargets)],
611
640
  },
641
+ // slc/link.md §Boss-event mapping: for BOSS_INTENT and
642
+ // BOSS_INTERRUPT the runtime, never the judge, attaches the exact
643
+ // original Boss text as `bossIntent`.
644
+ bossIntent: { source: 'text', required: true },
612
645
  },
613
646
  });
614
647
  }
@@ -839,8 +872,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
839
872
  : {}),
840
873
  };
841
874
  const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
842
- const classifyBossText = spec.classifyBossText ??
843
- makeDefaultClassifyBossText(machine, spec.entryEvent, spec.bossEvents ?? []);
875
+ // Build the derived classifier unconditionally: it is the sole validator of
876
+ // supplied `bossEvents`, and DR-019 §2 requires a conflicting duplicate to
877
+ // fail factory construction whether or not this spec overrides the
878
+ // classifier that would have consumed the contracts.
879
+ const derivedClassifyBossText = makeDefaultClassifyBossText(machine, spec.entryEvent, spec.bossEvents ?? []);
880
+ const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
844
881
  const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
845
882
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
846
883
  const statusesForState = spec.statusesForState ?? defaultStatusesForState;
@@ -1221,18 +1258,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
1221
1258
  await emitTrace('captain.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1222
1259
  throw error;
1223
1260
  }
1261
+ // A non-`ok` host result is a recoverable FSM failure (PBRT-47), so
1262
+ // it is never latched as a control-plane error; it is still
1263
+ // authoritative for the actor's error path even when the required
1264
+ // finish emission fails or a coincident boundary abort lands.
1224
1265
  let resultFailure;
1225
1266
  if (result.status !== 'ok') {
1226
- resultFailure = new Error(result.error ??
1227
- `captainActor: callCaptain status "${result.status}"`);
1267
+ resultFailure = markFsmResultFailure(new Error(result.error ??
1268
+ `captainActor: callCaptain status "${result.status}"`));
1228
1269
  }
1229
1270
  else if (result.finalText === undefined || result.finalText === '') {
1230
- resultFailure = new Error('captainActor: callCaptain returned status=ok with no finalText');
1231
- }
1232
- if (resultFailure !== undefined) {
1233
- // The host result is authoritative even when the required finish
1234
- // emission fails or triggers a coincident boundary abort.
1235
- controlPlaneError ??= resultFailure;
1271
+ resultFailure = markFsmResultFailure(new Error('captainActor: callCaptain returned status=ok with no finalText'));
1236
1272
  }
1237
1273
  try {
1238
1274
  await emitTrace('captain.call.finished', {
@@ -1290,12 +1326,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
1290
1326
  await drainEmissions();
1291
1327
  const prompt = composeCaptainPrompt(input);
1292
1328
  const result = await boundary.callCaptain(input, prompt, active);
1293
- if (result.status !== 'ok') {
1294
- throw new Error(result.error ??
1295
- `captainActor: callCaptain status "${result.status}"`);
1296
- }
1297
- if (result.finalText === undefined || result.finalText === '') {
1298
- throw new Error('captainActor: callCaptain returned status=ok with no finalText');
1329
+ // The boundary owns result validation (PBRT-47) and throws the
1330
+ // authoritative failure itself, so a returned result is always
1331
+ // `ok` with visible text. Assert that invariant rather than
1332
+ // restating the failure semantics, which would drift.
1333
+ if (result.status !== 'ok' || !result.finalText) {
1334
+ throw new Error('captainActor: boundary returned an unvalidated Captain result');
1299
1335
  }
1300
1336
  const judgePrompt = buildCaptainJudgePrompt(input, result.finalText);
1301
1337
  const raw = await boundary.callJudge('captain-output-adjudication', input.stateId, judgePrompt, active);
@@ -1304,8 +1340,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
1304
1340
  return output;
1305
1341
  }
1306
1342
  catch (error) {
1307
- if (!active.aborted)
1343
+ // A host-reported Captain result failure routes to the FSM's
1344
+ // failure state (PBRT-47); everything else here — a drained
1345
+ // emission failure, prompt composition, the port itself,
1346
+ // adjudication — is control plane.
1347
+ if (!active.aborted && !isFsmResultFailure(error)) {
1308
1348
  controlPlaneError ??= error;
1349
+ }
1309
1350
  throw error;
1310
1351
  }
1311
1352
  });
@@ -152,6 +152,31 @@ export const BOSS_REPLY_ERRORS = {
152
152
  `state ${stateId} declared needsBossReply but is not registered as resumable`,
153
153
  } as const;
154
154
 
155
+ // ---------------------------------------------------------------------------
156
+ // A host agent result that is not `ok` (or is `ok` with no final text) is a
157
+ // recoverable FSM failure, not a control-plane error: it travels the invoked
158
+ // actor's XState error path to the failure state and the public boundary
159
+ // resolves `failed` (PBRT-47, matching the player boundary's PBRT-9). The
160
+ // direct-Captain boundary has to emit its paired finish trace before
161
+ // rethrowing, so it needs to tell that failure apart from the control-plane
162
+ // errors it does latch — a thrown port, a malformed result, a rejecting sink.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const fsmResultFailures = new WeakSet<object>();
166
+
167
+ function markFsmResultFailure(error: Error): Error {
168
+ fsmResultFailures.add(error);
169
+ return error;
170
+ }
171
+
172
+ function isFsmResultFailure(error: unknown): boolean {
173
+ return (
174
+ typeof error === 'object' &&
175
+ error !== null &&
176
+ fsmResultFailures.has(error as object)
177
+ );
178
+ }
179
+
155
180
  // ---------------------------------------------------------------------------
156
181
  // The per-workflow spec. Every strategy member has a generic default derived
157
182
  // from the FSM artifact's own data, so a linker-emitted thin module normally
@@ -940,6 +965,20 @@ function configuredEventTypesForState(
940
965
  return configured;
941
966
  }
942
967
 
968
+ // Derived contracts merge into whatever the machine already yielded for the
969
+ // same type, so a deterministic entry event that shares a type with another
970
+ // derived contract keeps its exact-text ownership instead of being replaced.
971
+ function mergeDerivedContract(
972
+ contracts: Map<string, XStateBossEventSpec>,
973
+ contract: XStateBossEventSpec,
974
+ ): void {
975
+ const existing = contracts.get(contract.type);
976
+ contracts.set(contract.type, {
977
+ type: contract.type,
978
+ fields: { ...(existing?.fields ?? {}), ...(contract.fields ?? {}) },
979
+ });
980
+ }
981
+
943
982
  function defaultBossEventSpecs(
944
983
  machine: AnyStateMachine,
945
984
  entryEvent: { type: string; textField: string } | undefined,
@@ -947,7 +986,7 @@ function defaultBossEventSpecs(
947
986
  ): ReadonlyMap<string, XStateBossEventSpec> {
948
987
  const contracts = new Map<string, XStateBossEventSpec>();
949
988
  if (entryEvent !== undefined) {
950
- contracts.set(entryEvent.type, {
989
+ mergeDerivedContract(contracts, {
951
990
  type: entryEvent.type,
952
991
  fields: { [entryEvent.textField]: { source: 'text', required: true } },
953
992
  });
@@ -961,7 +1000,7 @@ function defaultBossEventSpecs(
961
1000
  const interruptTargets =
962
1001
  rootInterrupt === undefined ? [] : transitionTargets(rootInterrupt);
963
1002
  if (interruptTargets.length > 0) {
964
- contracts.set('BOSS_INTERRUPT', {
1003
+ mergeDerivedContract(contracts, {
965
1004
  type: 'BOSS_INTERRUPT',
966
1005
  fields: {
967
1006
  targetId: {
@@ -969,6 +1008,10 @@ function defaultBossEventSpecs(
969
1008
  required: true,
970
1009
  values: [...new Set(interruptTargets)],
971
1010
  },
1011
+ // slc/link.md §Boss-event mapping: for BOSS_INTENT and
1012
+ // BOSS_INTERRUPT the runtime, never the judge, attaches the exact
1013
+ // original Boss text as `bossIntent`.
1014
+ bossIntent: { source: 'text', required: true },
972
1015
  },
973
1016
  });
974
1017
  }
@@ -1313,9 +1356,16 @@ export function createXStatePlaybookRuntime<TOptions>(
1313
1356
  };
1314
1357
  const extractFields =
1315
1358
  spec.extractRequiredFields ?? defaultExtractRequiredFields;
1316
- const classifyBossText =
1317
- spec.classifyBossText ??
1318
- makeDefaultClassifyBossText(machine, spec.entryEvent, spec.bossEvents ?? []);
1359
+ // Build the derived classifier unconditionally: it is the sole validator of
1360
+ // supplied `bossEvents`, and DR-019 §2 requires a conflicting duplicate to
1361
+ // fail factory construction whether or not this spec overrides the
1362
+ // classifier that would have consumed the contracts.
1363
+ const derivedClassifyBossText = makeDefaultClassifyBossText(
1364
+ machine,
1365
+ spec.entryEvent,
1366
+ spec.bossEvents ?? [],
1367
+ );
1368
+ const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
1319
1369
  const normalizeTransitionEvent =
1320
1370
  spec.normalizeTransitionEvent ??
1321
1371
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
@@ -1815,22 +1865,25 @@ export function createXStatePlaybookRuntime<TOptions>(
1815
1865
  );
1816
1866
  throw error;
1817
1867
  }
1868
+ // A non-`ok` host result is a recoverable FSM failure (PBRT-47), so
1869
+ // it is never latched as a control-plane error; it is still
1870
+ // authoritative for the actor's error path even when the required
1871
+ // finish emission fails or a coincident boundary abort lands.
1818
1872
  let resultFailure: Error | undefined;
1819
1873
  if (result.status !== 'ok') {
1820
- resultFailure = new Error(
1821
- result.error ??
1822
- `captainActor: callCaptain status "${result.status}"`,
1874
+ resultFailure = markFsmResultFailure(
1875
+ new Error(
1876
+ result.error ??
1877
+ `captainActor: callCaptain status "${result.status}"`,
1878
+ ),
1823
1879
  );
1824
1880
  } else if (result.finalText === undefined || result.finalText === '') {
1825
- resultFailure = new Error(
1826
- 'captainActor: callCaptain returned status=ok with no finalText',
1881
+ resultFailure = markFsmResultFailure(
1882
+ new Error(
1883
+ 'captainActor: callCaptain returned status=ok with no finalText',
1884
+ ),
1827
1885
  );
1828
1886
  }
1829
- if (resultFailure !== undefined) {
1830
- // The host result is authoritative even when the required finish
1831
- // emission fails or triggers a coincident boundary abort.
1832
- controlPlaneError ??= resultFailure;
1833
- }
1834
1887
  try {
1835
1888
  await emitTrace(
1836
1889
  'captain.call.finished',
@@ -1903,15 +1956,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1903
1956
  await drainEmissions();
1904
1957
  const prompt = composeCaptainPrompt(input);
1905
1958
  const result = await boundary.callCaptain!(input, prompt, active);
1906
- if (result.status !== 'ok') {
1907
- throw new Error(
1908
- result.error ??
1909
- `captainActor: callCaptain status "${result.status}"`,
1910
- );
1911
- }
1912
- if (result.finalText === undefined || result.finalText === '') {
1959
+ // The boundary owns result validation (PBRT-47) and throws the
1960
+ // authoritative failure itself, so a returned result is always
1961
+ // `ok` with visible text. Assert that invariant rather than
1962
+ // restating the failure semantics, which would drift.
1963
+ if (result.status !== 'ok' || !result.finalText) {
1913
1964
  throw new Error(
1914
- 'captainActor: callCaptain returned status=ok with no finalText',
1965
+ 'captainActor: boundary returned an unvalidated Captain result',
1915
1966
  );
1916
1967
  }
1917
1968
  const judgePrompt = buildCaptainJudgePrompt(
@@ -1933,7 +1984,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1933
1984
  validateBossReplyOutput(input, output, resumableStateIds);
1934
1985
  return output;
1935
1986
  } catch (error) {
1936
- if (!active.aborted) controlPlaneError ??= error;
1987
+ // A host-reported Captain result failure routes to the FSM's
1988
+ // failure state (PBRT-47); everything else here — a drained
1989
+ // emission failure, prompt composition, the port itself,
1990
+ // adjudication — is control plane.
1991
+ if (!active.aborted && !isFsmResultFailure(error)) {
1992
+ controlPlaneError ??= error;
1993
+ }
1937
1994
  throw error;
1938
1995
  }
1939
1996
  },