@sublang/playbook 17.11.0 → 18.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.
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import { MAX_SESSION_CHUNK_BYTES } from './session-chunks.js';
5
+
6
+ export const SESSION_STORAGE_VERSION = 8;
7
+ const FIELDS = ['schemaVersion', 'kind', 'sessionId', 'cwd', 'state', 'createdAt', 'updatedAt', 'root'];
8
+ const SUMMARY = ['sessionId', 'cwd', 'state', 'createdAt', 'updatedAt'];
9
+
10
+ // Logical schema validation remains owned by portable-codec. This boundary
11
+ // validates the physical representation before giving that codec its input.
12
+ export async function decodeStoredManifest(bytes, chunks, sessionId) {
13
+ const value = JSON.parse(bytes);
14
+ if (value?.schemaVersion !== SESSION_STORAGE_VERSION) return value;
15
+ if (Buffer.byteLength(bytes) > MAX_SESSION_CHUNK_BYTES ||
16
+ !value || Array.isArray(value) || Object.keys(value).length !== FIELDS.length ||
17
+ FIELDS.some((key) => !Object.hasOwn(value, key)) ||
18
+ value.kind !== 'captain-session-storage' || value.sessionId !== sessionId) {
19
+ throw new Error('invalid bounded session manifest');
20
+ }
21
+ const manifest = await chunks.readJson(value.root);
22
+ if (manifest?.schemaVersion !== 7 || SUMMARY.some((key) => manifest[key] !== value[key])) {
23
+ throw new Error('bounded session summary differs from recovery');
24
+ }
25
+ return manifest;
26
+ }
27
+
28
+ export async function encodeStoredManifest(manifest, chunks) {
29
+ const root = await chunks.writeJson(manifest);
30
+ const value = { schemaVersion: SESSION_STORAGE_VERSION, kind: 'captain-session-storage' };
31
+ for (const key of SUMMARY) value[key] = manifest[key];
32
+ value.root = root;
33
+ const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
34
+ if (bytes.length > MAX_SESSION_CHUNK_BYTES) throw new Error('session manifest summary exceeds file bound');
35
+ return bytes;
36
+ }
@@ -9,6 +9,8 @@ export interface SessionBundle {
9
9
  readonly manifest: Uint8Array;
10
10
  readonly replay: Uint8Array;
11
11
  readonly assets: readonly AssetFile[];
12
+ /** Verified physical bundle files relative to sessionsDir; replay is a logical prefix. */
13
+ readonly files: readonly { readonly path: string; readonly byteLength: number }[];
12
14
  }
13
15
 
14
16
  export declare const RECORDS_STREAM_VERSION: 1;
package/slc/link.md CHANGED
@@ -1099,7 +1099,7 @@ Before the composer's result it places, in order:
1099
1099
  - once the run has heard a Boss reply, the run's Boss exchanges so far, each a `? ⟶` line and the verbatim question, then a `Boss ⟶` line and the verbatim reply, whatever the role and whether or not the Source retains them, leaving out the one exchange the call's own continuation carries;
1100
1100
  - for a call after a Boss reply, the marked verbatim question, only for a fresh conversation, and the marked verbatim reply.
1101
1101
 
1102
- A run begins at the entry event or a finished machine's restart, and its exchanges travel in the runtime snapshot as `bossExchanges`.
1102
+ A run begins at a deterministic entry or an event declared on the initial parked state itself, including a classified entry from recoverable failure, or at a finished machine's restart; a reply, global interrupt alone, or refused event starts no run. Its exchanges travel in the runtime snapshot as `bossExchanges`.
1103
1103
 
1104
1104
  The runtime chooses the compact prompt only after selecting a conversation token and carries the complete prompt as optional `PlayerCallOptions.freshPrompt`.
1105
1105
  A host starting fresh after a definite pre-execution token rejection uses `freshPrompt`, when supplied, instead of the compact prompt.
@@ -1181,6 +1181,15 @@ host's teardown resolution — and invokes no classifier judge call; the exact
1181
1181
  original text still rides only the runtime-owned textual payload field.
1182
1182
  All other non-empty turns shall use `callJudge` only to choose one of the FSM's
1183
1183
  event kinds and non-text routing fields, or no FSM action.
1184
+ An entry requiring a judge-selected routing field therefore omits deterministic
1185
+ `entryEvent` metadata and declares its exact fields through `bossEvents`.
1186
+ The shared engine derives that entry from the initial parked state's own
1187
+ transitions and resets the run's exchanges when it dispatches the entry.
1188
+ At a terminal state, the classifier uses those initial entry transitions and
1189
+ their guards under detached bound machine input. This evaluates pure context
1190
+ and assignment initialization under the supported FSM contract; it does not
1191
+ run returned executable actions or start actors. Global interrupts and reply
1192
+ events are not terminal entries.
1184
1193
  The classifier prompt shall include the exact, unmodified `turn.text` in a
1185
1194
  clearly labelled Boss-message block so the judge can make that choice. Omitting
1186
1195
  the message makes a parked-state classifier unable to distinguish an answer,
@@ -1849,9 +1858,9 @@ The `PlaybookRuntime` shall:
1849
1858
  cannot be recovered or validated, emit the one recovery status required
1850
1859
  by §Boss-event mapping, send no event, leave the actor unchanged, and
1851
1860
  return `no-action` after the ordinary settled boundary drains.
1852
- 3. Only after classification produces a real event, if the actor is in a
1861
+ 3. Only after classification produces a valid initial entry event, if the actor is in a
1853
1862
  `final` state, dispose and reconstruct it — `final` is terminal and cannot
1854
- accept new events. `NO_ACTION`, classifier rejection, and malformed
1863
+ accept new events. `NO_ACTION`, classifier rejection, invalid initial guards, and malformed
1855
1864
  classification shall leave a terminal actor untouched.
1856
1865
  4. Bind the active public-boundary signal and send the classified event to
1857
1866
  the actor.
@@ -19,7 +19,7 @@ import { spawn } from 'node:child_process';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { isDeepStrictEqual } from 'node:util';
21
21
  import PQueue from 'p-queue';
22
- import { createActor, fromPromise } from 'xstate';
22
+ import { createActor, fromPromise, initialTransition } from 'xstate';
23
23
  import { ACCEPTED_OUTCOME_ACTION_TYPE, createAcceptedOutcomeConsumer, } from './accepted-outcome.js';
24
24
  import { assertPlaybookFailureCause } from './runtime.js';
25
25
  import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, attachPlaybookFailureCause, combineAbortSignals, createNestedPlaybookBridge, describePlaybookSemanticValueShape, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, isPlaybookEffectLedgerMonotonicExtension, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, validateCaptainResult, validatePlayerResult, waitForPlaybookQuiescence, } from './xstate-runtime.js';
@@ -2490,7 +2490,7 @@ function activeStateIdsOf(snapshotOrState) {
2490
2490
  }
2491
2491
  function makeDefaultClassifyBossText(machine, entryEvent, bossEvents, parallelProfile) {
2492
2492
  const contracts = defaultBossEventSpecs(machine, entryEvent, bossEvents);
2493
- return async (text, ports, signal, snapshotOrState, boundary) => {
2493
+ return async (text, ports, signal, snapshotOrState, boundary, allowedEventTypes) => {
2494
2494
  const trimmed = text.trim();
2495
2495
  if (trimmed === '')
2496
2496
  return undefined;
@@ -2523,6 +2523,7 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents, parallelPr
2523
2523
  const severalPending = pendingQuestions.length > 1;
2524
2524
  const applicable = [...contracts.values()]
2525
2525
  .filter((contract) => configuredTypes.has(contract.type) &&
2526
+ (allowedEventTypes === undefined || allowedEventTypes.has(contract.type)) &&
2526
2527
  (contract.type !== 'BOSS_REPLY' || pendingQuestions.length > 0))
2527
2528
  .map((contract) => contract.type === 'BOSS_REPLY' && severalPending
2528
2529
  ? {
@@ -2807,7 +2808,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
2807
2808
  // fail factory construction whether or not this spec overrides the
2808
2809
  // classifier that would have consumed the contracts.
2809
2810
  const derivedClassifyBossText = makeDefaultClassifyBossText(machine, spec.entryEvent, spec.bossEvents ?? [], parallelProfile);
2810
- const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
2811
+ // Initial-state-owned events describe new runs independently of whether
2812
+ // their payload is deterministic. Root interrupts are deliberately absent.
2813
+ const initialEntryEventTypes = new Set((machine.root.initial.target ?? []).flatMap((node) => Object.keys(node.config.on ?? {}).filter((type) => type !== 'BOSS_REPLY' && type !== 'NO_ACTION')));
2811
2814
  const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
2812
2815
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
2813
2816
  const statusesForState = spec.statusesForState ??
@@ -3619,7 +3622,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
3619
3622
  }
3620
3623
  /** DR-090 §1: an entry event begins a new run, with no exchanges yet. */
3621
3624
  function beginRunIfEntry(event) {
3622
- if (spec.entryEvent !== undefined && event.type === spec.entryEvent.type) {
3625
+ if ((event.type === spec.entryEvent?.type || initialEntryEventTypes.has(event.type)) &&
3626
+ snapshotCan(actor?.getSnapshot(), event)) {
3623
3627
  bossExchanges = [];
3624
3628
  }
3625
3629
  }
@@ -7780,7 +7784,31 @@ export function createXStatePlaybookRuntime(machine, spec) {
7780
7784
  };
7781
7785
  }
7782
7786
  else {
7783
- event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
7787
+ // Classify a finished run against its fresh entry context,
7788
+ // without starting/replacing an actor or running the returned
7789
+ // executable actions. Pure context/assign initialization is
7790
+ // evaluated under the supported FSM contract.
7791
+ // Detach the input so pure initialization cannot mutate the
7792
+ // current actor or the host's bound input through an alias.
7793
+ const input = terminal
7794
+ ? machineInput(boundOptions, requireSession())
7795
+ : undefined;
7796
+ const classificationSnapshot = terminal
7797
+ ? initialTransition(machine, input === undefined
7798
+ ? undefined
7799
+ : snapshotJsonValue(input, 'initial classification input'))[0]
7800
+ : snapshot;
7801
+ event = spec.classifyBossText !== undefined
7802
+ ? await spec.classifyBossText(text, runtimePorts, signal, classificationSnapshot, boundary, boundOptions)
7803
+ : await derivedClassifyBossText(text, runtimePorts, signal, classificationSnapshot, boundary, terminal ? initialEntryEventTypes : undefined);
7804
+ if (terminal &&
7805
+ event !== undefined &&
7806
+ (!initialEntryEventTypes.has(event.type) ||
7807
+ (spec.classifyBossText !== undefined &&
7808
+ !snapshotCan(classificationSnapshot, event)))) {
7809
+ await runtimePorts.emitStatus('Classifier selected an invalid initial entry event');
7810
+ event = undefined;
7811
+ }
7784
7812
  }
7785
7813
  signal.throwIfAborted();
7786
7814
  }
@@ -20,7 +20,7 @@ import { spawn } from 'node:child_process';
20
20
  import { randomUUID } from 'node:crypto';
21
21
  import { isDeepStrictEqual } from 'node:util';
22
22
  import PQueue from 'p-queue';
23
- import { createActor, fromPromise } from 'xstate';
23
+ import { createActor, fromPromise, initialTransition } from 'xstate';
24
24
  import type {
25
25
  AnyStateMachine,
26
26
  EventObject,
@@ -3915,9 +3915,10 @@ function makeDefaultClassifyBossText(
3915
3915
  signal: AbortSignal,
3916
3916
  snapshotOrState: unknown,
3917
3917
  boundary?: RuntimeBoundaryCalls,
3918
+ allowedEventTypes?: ReadonlySet<string>,
3918
3919
  ) => Promise<EventObject | undefined> {
3919
3920
  const contracts = defaultBossEventSpecs(machine, entryEvent, bossEvents);
3920
- return async (text, ports, signal, snapshotOrState, boundary) => {
3921
+ return async (text, ports, signal, snapshotOrState, boundary, allowedEventTypes) => {
3921
3922
  const trimmed = text.trim();
3922
3923
  if (trimmed === '') return undefined;
3923
3924
  const state = classifierState(snapshotOrState);
@@ -3959,6 +3960,7 @@ function makeDefaultClassifyBossText(
3959
3960
  .filter(
3960
3961
  (contract) =>
3961
3962
  configuredTypes.has(contract.type) &&
3963
+ (allowedEventTypes === undefined || allowedEventTypes.has(contract.type)) &&
3962
3964
  (contract.type !== 'BOSS_REPLY' || pendingQuestions.length > 0),
3963
3965
  )
3964
3966
  .map((contract): XStateBossEventSpec =>
@@ -4407,9 +4409,15 @@ export function createXStatePlaybookRuntime<
4407
4409
  spec.bossEvents ?? [],
4408
4410
  parallelProfile,
4409
4411
  );
4410
- const classifyBossText: NonNullable<
4411
- XStatePlaybookRuntimeSpec<TOptions>['classifyBossText']
4412
- > = spec.classifyBossText ?? derivedClassifyBossText;
4412
+ // Initial-state-owned events describe new runs independently of whether
4413
+ // their payload is deterministic. Root interrupts are deliberately absent.
4414
+ const initialEntryEventTypes = new Set(
4415
+ (machine.root.initial.target ?? []).flatMap((node) =>
4416
+ Object.keys(node.config.on ?? {}).filter(
4417
+ (type) => type !== 'BOSS_REPLY' && type !== 'NO_ACTION',
4418
+ ),
4419
+ ),
4420
+ );
4413
4421
  const normalizeTransitionEvent =
4414
4422
  spec.normalizeTransitionEvent ??
4415
4423
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
@@ -5558,7 +5566,10 @@ export function createXStatePlaybookRuntime<
5558
5566
 
5559
5567
  /** DR-090 §1: an entry event begins a new run, with no exchanges yet. */
5560
5568
  function beginRunIfEntry(event: EventObject): void {
5561
- if (spec.entryEvent !== undefined && event.type === spec.entryEvent.type) {
5569
+ if (
5570
+ (event.type === spec.entryEvent?.type || initialEntryEventTypes.has(event.type)) &&
5571
+ snapshotCan(actor?.getSnapshot(), event)
5572
+ ) {
5562
5573
  bossExchanges = [];
5563
5574
  }
5564
5575
  }
@@ -10991,14 +11002,52 @@ export function createXStatePlaybookRuntime<
10991
11002
  [spec.entryEvent.textField]: text,
10992
11003
  };
10993
11004
  } else {
10994
- event = await classifyBossText(
10995
- text,
10996
- runtimePorts!,
10997
- signal,
10998
- snapshot,
10999
- boundary,
11000
- boundOptions,
11001
- );
11005
+ // Classify a finished run against its fresh entry context,
11006
+ // without starting/replacing an actor or running the returned
11007
+ // executable actions. Pure context/assign initialization is
11008
+ // evaluated under the supported FSM contract.
11009
+ // Detach the input so pure initialization cannot mutate the
11010
+ // current actor or the host's bound input through an alias.
11011
+ const input = terminal
11012
+ ? machineInput(boundOptions, requireSession())
11013
+ : undefined;
11014
+ const classificationSnapshot = terminal
11015
+ ? initialTransition(
11016
+ machine,
11017
+ input === undefined
11018
+ ? undefined
11019
+ : snapshotJsonValue(input, 'initial classification input'),
11020
+ )[0]
11021
+ : snapshot;
11022
+ event = spec.classifyBossText !== undefined
11023
+ ? await spec.classifyBossText(
11024
+ text,
11025
+ runtimePorts!,
11026
+ signal,
11027
+ classificationSnapshot,
11028
+ boundary,
11029
+ boundOptions,
11030
+ )
11031
+ : await derivedClassifyBossText(
11032
+ text,
11033
+ runtimePorts!,
11034
+ signal,
11035
+ classificationSnapshot,
11036
+ boundary,
11037
+ terminal ? initialEntryEventTypes : undefined,
11038
+ );
11039
+ if (
11040
+ terminal &&
11041
+ event !== undefined &&
11042
+ (!initialEntryEventTypes.has(event.type) ||
11043
+ (spec.classifyBossText !== undefined &&
11044
+ !snapshotCan(classificationSnapshot, event)))
11045
+ ) {
11046
+ await runtimePorts!.emitStatus(
11047
+ 'Classifier selected an invalid initial entry event',
11048
+ );
11049
+ event = undefined;
11050
+ }
11002
11051
  }
11003
11052
  signal.throwIfAborted();
11004
11053
  }