@sublang/playbook 12.2.2 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/docs/cli.md +56 -52
  2. package/docs/configuration.md +2 -2
  3. package/docs/embedding.md +68 -32
  4. package/package.json +11 -3
  5. package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
  6. package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
  7. package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
  8. package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
  9. package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
  10. package/reference/sdlc/code.playbook/bin/run.js +62 -18
  11. package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
  12. package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
  13. package/reference/sdlc/code.playbook/code.fsm.js +43 -5
  14. package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
  15. package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
  17. package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
  18. package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
  19. package/reference/sdlc/code.playbook/session-host.js +18 -0
  20. package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
  21. package/reference/sdlc/code.playbook/session-store.js +22 -6
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
  24. package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
  25. package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
  26. package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
  27. package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
  28. package/reference/sdlc/review.playbook/review.fsm.js +11 -1
  29. package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
  30. package/slc/gears2fsm.md +21 -3
  31. package/slc/link.md +21 -2
  32. package/src/runtime.d.ts +7 -0
  33. package/src/runtime.ts +12 -0
  34. package/src/xstate-playbook-runtime.d.ts +13 -0
  35. package/src/xstate-playbook-runtime.js +75 -13
  36. package/src/xstate-playbook-runtime.ts +87 -21
  37. package/src/xstate-runtime.js +35 -4
  38. package/src/xstate-runtime.ts +49 -4
@@ -103,6 +103,11 @@ type SnapshotAgentEnvelope = DeepReadonly<
103
103
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
104
104
 
105
105
  export interface PlaybookCaptainDeps {
106
+ continuity?: {
107
+ beforeCall(participantId: string): Promise<void>;
108
+ acknowledged(participantId: string, token: string): void;
109
+ reset(participantId: string, reason: 'missing_hint' | 'rejected_hint'): Promise<void>;
110
+ };
106
111
  loadModule?: (specifier: string) => Promise<unknown>;
107
112
  createSessionId?: () => string;
108
113
  hostCapabilities?: Readonly<
@@ -549,8 +554,24 @@ const INTERNAL_CAPTAIN_ID = 'captain';
549
554
  const UUID_PATTERN =
550
555
  /^[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
556
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
552
- const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
557
+ const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
553
558
  const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
559
+
560
+ // PBCLI-4: a local role id is its source role name lowercased by Unicode case
561
+ // mapping — nonempty, free of whitespace and control characters, and equal to
562
+ // its own lowercase form. The rule restricts neither script nor alphabet, so
563
+ // `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
564
+ // enforce the reserved `captain` name separately. The CLI host owns the same
565
+ // predicate in `bin/session-store.js`; that private module already imports
566
+ // this one, so the text is repeated here rather than cycled back.
567
+ function isCanonicalLocalRoleId(value: unknown): value is string {
568
+ return (
569
+ typeof value === 'string' &&
570
+ value.length > 0 &&
571
+ value === value.toLowerCase() &&
572
+ !ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
573
+ );
574
+ }
554
575
  const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
555
576
  'reconcile:unresolved-effect';
556
577
  const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
@@ -1452,9 +1473,7 @@ function isValidRegistryEntry(
1452
1473
  !Array.isArray(e.requiredRoleIds) ||
1453
1474
  e.requiredRoleIds.some(
1454
1475
  (role) =>
1455
- typeof role !== 'string' ||
1456
- !ROLE_ID_PATTERN.test(role) ||
1457
- role === INTERNAL_CAPTAIN_ID,
1476
+ !isCanonicalLocalRoleId(role) || role === INTERNAL_CAPTAIN_ID,
1458
1477
  ) ||
1459
1478
  new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
1460
1479
  !Array.isArray(e.concurrentRoleSets)
@@ -1773,7 +1792,7 @@ function snapshotFrameRoleBindings(
1773
1792
  const bindings = snapshotRecord(value, path);
1774
1793
  return Object.fromEntries(
1775
1794
  Object.entries(bindings).map(([roleId, raw]) => {
1776
- if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
1795
+ if (!isCanonicalLocalRoleId(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
1777
1796
  throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
1778
1797
  }
1779
1798
  const playerId = snapshotString(raw, `${path}.${roleId}`);
@@ -1805,6 +1824,7 @@ function normalizeHostPlayerResult(
1805
1824
  'resumeToken',
1806
1825
  'finalText',
1807
1826
  'error',
1827
+ 'errorCode',
1808
1828
  ]);
1809
1829
  const normalized: Record<string, unknown> = {};
1810
1830
  for (const key of Reflect.ownKeys(descriptors)) {
@@ -1827,13 +1847,18 @@ function normalizeHostPlayerResult(
1827
1847
  const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
1828
1848
  rejectSnapshotKeys(
1829
1849
  record,
1830
- ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
1850
+ ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error', 'errorCode'],
1831
1851
  path,
1832
1852
  );
1833
1853
  if (record.playerId !== expectedPlayerId) {
1834
1854
  throw new TypeError(`${path}.playerId does not match the requested player`);
1835
1855
  }
1836
1856
  snapshotInteger(record.turnId, `${path}.turnId`, 1);
1857
+ if (record.errorCode !== undefined &&
1858
+ (record.errorCode !== 'SESSION_RESUME_REJECTED' ||
1859
+ record.status !== 'error' || record.resumeToken !== undefined)) {
1860
+ throw new TypeError(`${path}.errorCode is not a definite resume rejection`);
1861
+ }
1837
1862
  return validatePlayerResult(
1838
1863
  {
1839
1864
  status: record.status,
@@ -2974,6 +2999,7 @@ export function createPlaybookCaptainShell(
2974
2999
  PlaybookCaptainDeps['createCaptainRuntime']
2975
3000
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2976
3001
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
3002
+ const continuity = deps.continuity;
2977
3003
  let pendingHostCapabilities = deps.hostCapabilities;
2978
3004
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
2979
3005
  // The returned shell must not retain the caller's aggregate dependency
@@ -4362,16 +4388,37 @@ export function createPlaybookCaptainShell(
4362
4388
  try {
4363
4389
  let rawResult: unknown;
4364
4390
  try {
4365
- rawResult = await trackHostCall(
4366
- frame,
4367
- classifySettingsCall(() =>
4368
- context.callPlayer(binding.playerId, prompt, {
4369
- resume: options.resume,
4370
- settings,
4371
- }),
4372
- ),
4373
- );
4374
- hostResolved = true;
4391
+ const call = async (resume: string | false): Promise<unknown> => {
4392
+ await continuity?.beforeCall(binding.playerId);
4393
+ signal.throwIfAborted();
4394
+ const raw = await trackHostCall(
4395
+ frame,
4396
+ classifySettingsCall(() =>
4397
+ context.callPlayer(binding.playerId, prompt, { resume, settings }),
4398
+ ),
4399
+ );
4400
+ hostResolved = true;
4401
+ return raw;
4402
+ };
4403
+ if (options.resume === false) {
4404
+ await continuity?.reset(binding.playerId, 'missing_hint');
4405
+ }
4406
+ rawResult = await call(options.resume);
4407
+ normalizeHostPlayerResult(rawResult, binding.playerId);
4408
+ if (typeof options.resume === 'string' && options.resume.length > 0 &&
4409
+ Object.getOwnPropertyDescriptor(rawResult as object, 'errorCode')?.value ===
4410
+ 'SESSION_RESUME_REJECTED') {
4411
+ if (playerTransactions.get(binding.playerId) !== calling ||
4412
+ calling.abandoned || signal.aborted || activeTurn !== admittedTurn ||
4413
+ frame.playerCallScope !== scope || !frames.includes(frame)) {
4414
+ signal.throwIfAborted();
4415
+ throw new Error(`${frameLabel(frame)} player rejection arrived after its runtime operation ended`);
4416
+ }
4417
+ delete ledger.resumeToken;
4418
+ await continuity?.reset(binding.playerId, 'rejected_hint');
4419
+ hostResolved = false;
4420
+ rawResult = await call(false);
4421
+ }
4375
4422
  } catch (error) {
4376
4423
  if (error instanceof AgentSettingsPreflightError) {
4377
4424
  if (
@@ -4725,6 +4772,9 @@ export function createPlaybookCaptainShell(
4725
4772
  try {
4726
4773
  if (resumeToken === undefined) delete ledger.resumeToken;
4727
4774
  else ledger.resumeToken = resumeToken;
4775
+ if (pending.status === 'ok' && resumeToken !== undefined) {
4776
+ continuity?.acknowledged(binding.playerId, resumeToken);
4777
+ }
4728
4778
  } finally {
4729
4779
  playerTransactions.delete(binding.playerId);
4730
4780
  }
@@ -5177,6 +5227,12 @@ export function createPlaybookCaptainShell(
5177
5227
  childSessionId: frame.sessionId,
5178
5228
  state: result.state,
5179
5229
  ...(result.output !== undefined ? { output: result.output } : {}),
5230
+ // DR-048: the child runtime read this record from its own artifact;
5231
+ // the host relays it unchanged so the caller's bridge can route a
5232
+ // failure terminal without knowing the callee's output fields.
5233
+ ...(result.terminal !== undefined
5234
+ ? { terminal: result.terminal }
5235
+ : {}),
5180
5236
  };
5181
5237
  }
5182
5238
  if (result.outcome === 'aborted') {
@@ -6141,7 +6197,7 @@ export function createPlaybookCaptainShell(
6141
6197
  class CaptainContinuityError extends Error {
6142
6198
  constructor(cause: unknown) {
6143
6199
  super(
6144
- 'the session Captain conversation could not be resynchronized after one reseeded re-issue',
6200
+ 'the session Captain conversation lost continuity',
6145
6201
  { cause },
6146
6202
  );
6147
6203
  this.name = 'CaptainContinuityError';
@@ -6165,8 +6221,11 @@ export function createPlaybookCaptainShell(
6165
6221
  finalText?: string;
6166
6222
  resumeToken?: string;
6167
6223
  error?: string;
6224
+ errorCode?: 'SESSION_RESUME_REJECTED';
6168
6225
  }> => {
6169
6226
  const queued = captainQueue.add(async () => {
6227
+ context.signal.throwIfAborted();
6228
+ await continuity?.beforeCall('captain');
6170
6229
  context.signal.throwIfAborted();
6171
6230
  attempt.providerBoundaryEntered = true;
6172
6231
  const result = await classifySettingsCall(() =>
@@ -6185,14 +6244,12 @@ export function createPlaybookCaptainShell(
6185
6244
  finalText?: string;
6186
6245
  resumeToken?: string;
6187
6246
  error?: string;
6247
+ errorCode?: 'SESSION_RESUME_REJECTED';
6188
6248
  }>;
6189
6249
  };
6190
6250
 
6191
- // CAPTAIN-35: unsynchronized when the call throws, returns non-`ok`, or
6192
- // returns `ok` without a token. Exactly one re-issue on a fresh conversation
6193
- // seeded with the reseed digest plus the current ControlView digest. A
6194
- // conversation that is owed a reseed carries the digest on its very next
6195
- // call, so the turn after a failed reseed starts seeded rather than blank.
6251
+ // Only proven pre-execution rejection permits an immediate fresh call.
6252
+ // Other continuity loss leaves the journal reseed for the next Boss turn.
6196
6253
  const durableCall = async (
6197
6254
  context: CaptainContext,
6198
6255
  compose: (options: { reseedDigest?: string }) => string,
@@ -6209,10 +6266,11 @@ export function createPlaybookCaptainShell(
6209
6266
  const representedJournalSeq = journalSeq;
6210
6267
  const firstAttempt = { providerBoundaryEntered: false };
6211
6268
  let result:
6212
- | { status: string; finalText?: string; resumeToken?: string; error?: string }
6269
+ | { status: string; finalText?: string; resumeToken?: string; error?: string; errorCode?: 'SESSION_RESUME_REJECTED' }
6213
6270
  | undefined;
6214
6271
  let failure: unknown;
6215
6272
  try {
6273
+ if (resume === false) await continuity?.reset('captain', 'missing_hint');
6216
6274
  result = await rawDurableCall(
6217
6275
  context,
6218
6276
  compose(
@@ -6259,9 +6317,10 @@ export function createPlaybookCaptainShell(
6259
6317
  failure !== undefined ||
6260
6318
  result === undefined ||
6261
6319
  result.status !== 'ok' ||
6262
- result.resumeToken === undefined;
6320
+ typeof result.resumeToken !== 'string' || result.resumeToken.trim().length === 0;
6263
6321
  if (!unsynchronized) {
6264
6322
  conversation = { kind: 'pinned', token: result!.resumeToken! };
6323
+ continuity?.acknowledged('captain', result!.resumeToken!);
6265
6324
  if (activeTurn) {
6266
6325
  activeTurn.captainSyncedJournalSeq = representedJournalSeq;
6267
6326
  }
@@ -6277,9 +6336,16 @@ export function createPlaybookCaptainShell(
6277
6336
  // stays `needsSeeding` until a call comes back with a token, so a reseed
6278
6337
  // that itself fails leaves the obligation standing for the next turn.
6279
6338
  conversation = { kind: 'needsSeeding' };
6339
+ if (typeof resume !== 'string' || resume.length === 0 ||
6340
+ result?.status !== 'error' || result.errorCode !== 'SESSION_RESUME_REJECTED') {
6341
+ throw markControlFailure(new CaptainContinuityError(
6342
+ failure ?? result?.error ?? 'callCaptain did not establish continuation',
6343
+ ));
6344
+ }
6345
+ await continuity?.reset('captain', 'rejected_hint');
6280
6346
  const recap = reseedDigest();
6281
6347
  let reissued:
6282
- | { status: string; finalText?: string; resumeToken?: string; error?: string }
6348
+ | { status: string; finalText?: string; resumeToken?: string; error?: string; errorCode?: 'SESSION_RESUME_REJECTED' }
6283
6349
  | undefined;
6284
6350
  const reissueAttempt = { providerBoundaryEntered: false };
6285
6351
  try {
@@ -6299,7 +6365,7 @@ export function createPlaybookCaptainShell(
6299
6365
  }
6300
6366
  throw markControlFailure(new CaptainContinuityError(error));
6301
6367
  }
6302
- if (reissued.status !== 'ok' || reissued.resumeToken === undefined) {
6368
+ if (reissued.status !== 'ok' || typeof reissued.resumeToken !== 'string' || reissued.resumeToken.trim().length === 0) {
6303
6369
  throw markControlFailure(
6304
6370
  new CaptainContinuityError(
6305
6371
  reissued.error ??
@@ -6308,6 +6374,7 @@ export function createPlaybookCaptainShell(
6308
6374
  );
6309
6375
  }
6310
6376
  conversation = { kind: 'pinned', token: reissued.resumeToken };
6377
+ continuity?.acknowledged('captain', reissued.resumeToken);
6311
6378
  if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
6312
6379
  return {
6313
6380
  ...(reissued.finalText !== undefined
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import type { PlaybookCaptainShell, PlaybookCaptainShellSnapshot } from './playbook-captain.js';
5
+ import type { PlaybookEffectLedger } from './host-capabilities.js';
6
+ import type { PlaybookSessionLifecycle, SessionExecutionProjection, SessionStructuralProjection, SessionFreshBoundary, SessionRecovery } from './session-store.js';
7
+
8
+ export interface SessionHost {
9
+ readonly shell: PlaybookCaptainShell;
10
+ readonly host: { runBossTurn(input: string): Promise<void>; abortActiveTurn(): void; dispose(): Promise<void>; [key: string]: any };
11
+ readonly snapshot: PlaybookCaptainShellSnapshot;
12
+ reconcileRepositoryEffects(): Promise<PlaybookEffectLedger>;
13
+ }
14
+ export interface SessionHostOptions {
15
+ readonly config: SessionExecutionProjection;
16
+ readonly sessionId?: string;
17
+ readonly cwd: string;
18
+ readonly sessionLease: PlaybookSessionLifecycle;
19
+ readonly loadModule: (specifier: string) => Promise<any>;
20
+ readonly restoreSnapshot?: PlaybookCaptainShellSnapshot;
21
+ readonly reconcileUncertainTurnReplay?: boolean;
22
+ readonly signal?: AbortSignal;
23
+ readonly [key: string]: any;
24
+ }
25
+ export declare function createCaptainSessionHost(options: SessionHostOptions): Promise<SessionHost>;
26
+ export declare function installRetainedGenerationsForLaunch(options: {
27
+ lease: PlaybookSessionLifecycle;
28
+ shell: PlaybookCaptainShell;
29
+ freshBoundary?: SessionFreshBoundary;
30
+ onFreshRecord?: (record: SessionRecovery | undefined) => void;
31
+ onLegacyRecord?: (record: any) => void | Promise<void>;
32
+ onInvalidRecord?: (record: any) => void | Promise<void>;
33
+ retainedGenerations?: Readonly<Record<string, any>>;
34
+ reconcileRepositoryEffects?: () => Promise<PlaybookEffectLedger>;
35
+ }): Promise<Readonly<Record<string, any>>>;
36
+ export declare function executionConfigFromPlan(plan: any): SessionExecutionProjection;
37
+ export declare function validateFrozenExecutionConfig(structural: SessionStructuralProjection, execution: SessionExecutionProjection, dependencies: { loadModule: (specifier: string) => Promise<any>; prepareRegistryModule?: (request: {id: string; from: string; authoredFrom: string}) => Promise<string | void> }): Promise<SessionExecutionProjection>;
38
+ export declare function driveHeadlessCaptainTurn(options: any): Promise<any>;
39
+ export declare function normalizeLaunchPlan(top: any, options?: any): Promise<any>;
40
+ export declare function loadLaunchPlan(options: any): Promise<any>;
41
+ export declare function composeGenericConfig(top: any, loadModule: (specifier: string) => Promise<any>, configPath?: string): Promise<any>;
42
+ export declare function projectTmuxConfig(plan: any): any;
43
+ export declare function resolveLaunchSessionsDir(options: any): string;
44
+
45
+ import type { SharedSessionStore, ReplayStreamStatus, ReplayStreamEntry, SessionGraph } from './session-store.js';
46
+ export interface OpenSessionHostOptions {
47
+ readonly store?: SharedSessionStore;
48
+ readonly sessionsDir?: string;
49
+ readonly sessionId?: string;
50
+ readonly mode?: 'new' | 'continue' | 'retry';
51
+ readonly cwd?: string;
52
+ readonly config?: SessionExecutionProjection;
53
+ readonly plan?: any;
54
+ readonly loadModule?: (specifier: string) => Promise<any>;
55
+ readonly prepareRegistryModule?: (request: {id: string; from: string; authoredFrom: string}) => Promise<string | void>;
56
+ readonly observers?: readonly { onRecord?(record: any): void | Promise<void> }[];
57
+ readonly onStoredRecord?: (record: ReplayStreamEntry, status: ReplayStreamStatus) => void | Promise<void>;
58
+ readonly onCheckpoint?: (record: SessionRecovery) => void | Promise<void>;
59
+ readonly onIncomplete?: () => void | Promise<void>;
60
+ readonly graphs?: readonly { playbookId: string; graph: SessionGraph | null }[];
61
+ readonly initialVisible?: readonly string[];
62
+ readonly [key: string]: any;
63
+ }
64
+ export interface SessionHostController {
65
+ readonly sessionId: string;
66
+ readonly host: SessionHost['host'];
67
+ readonly shell: PlaybookCaptainShell;
68
+ readonly lease: PlaybookSessionLifecycle;
69
+ read(): Promise<SessionRecovery | undefined>;
70
+ handleBossTurn(input: string): Promise<SessionRecovery>;
71
+ retry(): Promise<SessionRecovery>;
72
+ dispose(): Promise<void>;
73
+ }
74
+ export declare function openSessionHost(options: OpenSessionHostOptions): Promise<SessionHostController>;
75
+ export declare function discardSessionUncertain(store: SharedSessionStore, sessionId: string): Promise<SessionRecovery | undefined>;
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ export {
5
+ createCaptainSessionHost,
6
+ installRetainedGenerationsForLaunch,
7
+ executionConfigFromPlan,
8
+ validateFrozenExecutionConfig,
9
+ driveHeadlessCaptainTurn,
10
+ } from './bin/run.js';
11
+ export {
12
+ normalizeLaunchPlan,
13
+ loadLaunchPlan,
14
+ composeGenericConfig,
15
+ projectTmuxConfig,
16
+ resolveLaunchSessionsDir,
17
+ } from './bin/launch-config.js';
18
+ export { openSessionHost, discardSessionUncertain } from './bin/session-host.js';
@@ -48,7 +48,7 @@ export type ReplayStreamStatus =
48
48
  export interface PlaybookSessionSummary {
49
49
  readonly schemaVersion: number;
50
50
  readonly sessionId: string;
51
- readonly state: 'settled' | 'uncertain';
51
+ readonly state: 'settled' | 'uncertain' | 'history-only';
52
52
  readonly cwd: string;
53
53
  readonly updatedAt: string;
54
54
  }
@@ -80,3 +80,178 @@ export interface PlaybookSessionLease {
80
80
  streamStatus(): ReplayStreamStatus;
81
81
  release(): Promise<ReplayStreamStatus>;
82
82
  }
83
+
84
+ /** Recovery data is interpreted by the version-aware codec, not by live runtimes. */
85
+ export type SessionSnapshot = Readonly<Record<string, any>>;
86
+ export interface SessionEffectLedger {
87
+ readonly schemaVersion: 1;
88
+ readonly revision: number;
89
+ readonly boundaries: readonly Readonly<Record<string, any>>[];
90
+ readonly logicalOperations: readonly Readonly<Record<string, any>>[];
91
+ }
92
+ export interface SessionUnresolvedEffect {
93
+ readonly classification: 'one-descendant-commit' | 'multiple-commits' |
94
+ 'rewritten-or-non-descendant' | 'worktree-only-change' |
95
+ 'concurrent-or-foreign-change' | 'observation-ambiguous' | 'incomplete';
96
+ readonly baselineHead: string;
97
+ readonly afterHead?: string;
98
+ readonly commitOid?: string;
99
+ }
100
+ export type SessionRetentionUpdate =
101
+ | { readonly kind: 'retain'; readonly rootPlaybookId: string; readonly generation: Readonly<Record<string, any>> }
102
+ | { readonly kind: 'clear'; readonly rootPlaybookId: string };
103
+
104
+ export interface SessionExecutionProjection {
105
+ readonly schemaVersion: 2;
106
+ readonly captain: Readonly<Record<string, any>>;
107
+ readonly players: readonly Readonly<Record<string, any>>[];
108
+ readonly catalog: Readonly<Record<string, any>>;
109
+ }
110
+ export interface SessionStructuralProjection {
111
+ readonly schemaVersion: 1;
112
+ readonly captain: Readonly<Record<string, any>>;
113
+ readonly players: readonly Readonly<Record<string, any>>[];
114
+ readonly catalog: Readonly<Record<string, any>>;
115
+ }
116
+ export interface SessionRecovery {
117
+ readonly schemaVersion: 6;
118
+ readonly kind: 'captain-session';
119
+ readonly sessionId: string;
120
+ readonly cwd: string;
121
+ readonly createdAt: string;
122
+ readonly updatedAt: string;
123
+ readonly state: 'settled' | 'uncertain';
124
+ readonly structuralProjection: SessionStructuralProjection;
125
+ readonly lastAppliedExecutionProjection: SessionExecutionProjection;
126
+ readonly snapshot: SessionSnapshot;
127
+ readonly effectLedger: SessionEffectLedger;
128
+ readonly unresolvedEffects: readonly SessionUnresolvedEffect[];
129
+ readonly retainedGenerations?: Readonly<Record<string, any>>;
130
+ readonly uncertain?: {
131
+ readonly input: string;
132
+ readonly attemptId: string;
133
+ readonly attemptNumber: number;
134
+ readonly baseUpdatedAt: string | null;
135
+ readonly markedAt: string;
136
+ readonly attemptedExecutionProjection: SessionExecutionProjection;
137
+ readonly abandonment?: Readonly<Record<string, any>>;
138
+ };
139
+ }
140
+ export interface SessionReplayCheckpoint {
141
+ readonly seq: number;
142
+ readonly sha256: string;
143
+ readonly incomplete: boolean;
144
+ }
145
+ export type SessionManifest = Omit<SessionRecovery, 'schemaVersion'> & {
146
+ readonly schemaVersion: 7;
147
+ readonly replay: SessionReplayCheckpoint;
148
+ readonly contextSeq: number;
149
+ } | {
150
+ readonly schemaVersion: 7;
151
+ readonly kind: 'captain-session';
152
+ readonly state: 'history-only';
153
+ readonly sessionId: string;
154
+ readonly cwd: string;
155
+ readonly createdAt: string;
156
+ readonly updatedAt: string;
157
+ readonly replay: SessionReplayCheckpoint;
158
+ readonly contextSeq: number | null;
159
+ readonly reason: string;
160
+ };
161
+ export type StoredSessionManifest = SessionManifest | {
162
+ readonly schemaVersion: number;
163
+ readonly sessionId: string;
164
+ readonly cwd?: unknown;
165
+ readonly [key: string]: unknown;
166
+ };
167
+ export interface SessionGraph {
168
+ readonly initial: string;
169
+ readonly nodes: readonly { readonly id: string; readonly kind: 'state' | 'final'; readonly tags: readonly string[]; readonly parent?: string; readonly role?: string; readonly description?: string }[];
170
+ readonly edges: readonly { readonly id: string; readonly from: string; readonly to: string; readonly event: string }[];
171
+ }
172
+ export interface SessionContext {
173
+ readonly type: 'session_context';
174
+ readonly timestamp: number;
175
+ readonly contextVersion: 1;
176
+ readonly captainId: string;
177
+ readonly configuration: SessionExecutionProjection;
178
+ readonly graphs: readonly { readonly playbookId: string; readonly graph: SessionGraph | null }[];
179
+ readonly initialVisible: readonly string[];
180
+ }
181
+ export interface SessionHistory extends ReplayStreamReadResult {
182
+ readonly synthetic?: true;
183
+ readonly missing: boolean;
184
+ readonly incomplete: boolean;
185
+ readonly pendingTail: boolean;
186
+ readonly damage?: { readonly seq: number; readonly offset: number; readonly reason: string };
187
+ readonly digests: readonly string[];
188
+ readonly completeBytes: number;
189
+ }
190
+ export interface SessionValidation {
191
+ readonly integrityValid: boolean;
192
+ readonly sessionId: string;
193
+ readonly resumable: boolean;
194
+ readonly reasons: readonly string[];
195
+ readonly manifest: StoredSessionManifest;
196
+ readonly history: SessionHistory;
197
+ }
198
+ export interface SessionHints {
199
+ readonly players: Readonly<Record<string, string>>;
200
+ readonly captain?: { readonly kind: 'pinned'; readonly token: string } | { readonly kind: 'needsCatchUp'; readonly resume: string | false; readonly afterJournalSeq: number };
201
+ }
202
+ export interface SessionFreshBoundary {
203
+ readonly cwd: string;
204
+ readonly structuralProjection: SessionStructuralProjection;
205
+ readonly executionProjection: SessionExecutionProjection;
206
+ readonly snapshot: SessionSnapshot;
207
+ readonly context?: SessionContext;
208
+ readonly onLegacyRecord?: (record: any) => void | Promise<void>;
209
+ readonly onInvalidRecord?: (record: any) => void | Promise<void>;
210
+ }
211
+ export interface PlaybookSessionLifecycle extends PlaybookSessionLease {
212
+ read(): Promise<SessionRecovery | undefined>;
213
+ readManifest(): Promise<StoredSessionManifest>;
214
+ initializeSettledWithPredecessor(options: SessionFreshBoundary): Promise<SessionRecovery>;
215
+ abandonFreshSettled(options: { expected: SessionRecovery }): Promise<boolean>;
216
+ beginTurn(options: { input: string; attemptId: string; attemptedExecutionProjection: SessionExecutionProjection }): Promise<SessionRecovery>;
217
+ beginRetry(options: { expectedAttemptId: string; nextAttemptId: string }): Promise<SessionRecovery>;
218
+ settle(options: { attemptId: string; snapshot: SessionSnapshot; unresolvedEffects: readonly SessionUnresolvedEffect[]; retentionUpdates?: readonly SessionRetentionUpdate[] }): Promise<SessionRecovery>;
219
+ discard(options: { attemptId: string }): Promise<SessionRecovery | undefined>;
220
+ beginUnresolvedEffectAbandonment(options: any): Promise<any>;
221
+ completeUnresolvedEffectAbandonment(options: any): Promise<any>;
222
+ recoverUnresolvedEffectAbandonment(): Promise<SessionRecovery | undefined>;
223
+ writeEffectLedger(authority: any, commands: readonly Readonly<Record<string, any>>[]): Promise<SessionEffectLedger>;
224
+ assertOwner(): Promise<any>;
225
+ assertContinuable(context?: { cwd?: string; executionProjection?: SessionExecutionProjection }): Promise<SessionValidation>;
226
+ recordContext(context: SessionContext): Promise<number>;
227
+ consumeHints(): Promise<SessionHints>;
228
+ acknowledgeHint(participantId: string, token: string): void;
229
+ clearHint(participantId: string): void;
230
+ }
231
+ export interface SharedSessionStore {
232
+ readonly sessionsDir: string;
233
+ prepare(): Promise<void>;
234
+ read(sessionId: string): Promise<SessionRecovery>;
235
+ readManifest(sessionId: string): Promise<StoredSessionManifest>;
236
+ /** Observation only; mutations still require acquiring the lease. */
237
+ readLeaseState(sessionId: string): Promise<'active' | 'idle' | 'unknown'>;
238
+ readHistory(sessionId: string, options?: ReplayStreamReadOptions): Promise<SessionHistory>;
239
+ readStream(sessionId: string, options?: ReplayStreamReadOptions): Promise<ReplayStreamReadResult>;
240
+ readSummary(sessionId: string): Promise<PlaybookSessionSummary>;
241
+ listSummaries(): Promise<PlaybookSessionListResult>;
242
+ latest(options?: { preferredCwd?: string; onLegacyRecord?: (record: any) => void | Promise<void> }): Promise<SessionRecovery>;
243
+ acquire(sessionId: string): Promise<PlaybookSessionLifecycle>;
244
+ acquireManagement(sessionId: string): Promise<{ readonly sessionId: string; readonly ownerToken: string; assertOwner(): Promise<unknown>; release(): Promise<void> }>;
245
+ validate(sessionId: string, context?: { cwd?: string; executionProjection?: SessionExecutionProjection }): Promise<SessionValidation>;
246
+ delete(sessionId: string): Promise<void>;
247
+ migrateLegacyDefault(options?: { env?: Readonly<Record<string, string | undefined>>; homeDir?: string }): Promise<{ sourceDir: string; migrated: readonly string[]; skipped: readonly { sessionId: string; reason: string }[] }>;
248
+ migrate(sessionId: string, options?: { sourcePath?: string; cwd?: string; backupDir?: string }): Promise<{ manifest: SessionManifest; migrated: boolean; reasons: readonly string[] }>;
249
+ }
250
+ export declare function createSessionStore(options?: { sessionsDir?: string; env?: Readonly<Record<string, string | undefined>>; homeDir?: string; [key: string]: any }): SharedSessionStore;
251
+ export declare function validateSessionManifest(value: unknown): SessionManifest;
252
+ export declare function validateSessionContext(value: unknown): SessionContext;
253
+ export declare function projectCaptainSessionStructure(value: SessionExecutionProjection): SessionStructuralProjection;
254
+ export declare function validateCaptainSessionExecutionProjection(value: unknown): SessionExecutionProjection;
255
+ export declare function validateCaptainSessionStructuralProjection(value: unknown): SessionStructuralProjection;
256
+ export declare function assertCaptainSessionExecutionCompatible(structural: SessionStructuralProjection, execution: SessionExecutionProjection): SessionExecutionProjection;
257
+ export declare function attachSessionHints(snapshot: SessionSnapshot, hints: SessionHints): SessionSnapshot;
@@ -21,14 +21,17 @@ export function openSessionStore(sessionsDir) {
21
21
  }
22
22
 
23
23
  function wrapStore(store) {
24
+ let preparation;
25
+ const prepared = async (operation) => {
26
+ await (preparation ??= store.prepare());
27
+ return operation();
28
+ };
24
29
  return Object.freeze({
25
30
  sessionsDir: store.sessionsDir,
26
- list: async () => projectListResult(await store.listSummaries()),
27
- read: async (sessionId) =>
28
- projectSummary(await store.readSummary(sessionId)),
29
- readStream: async (sessionId, options) =>
30
- projectReadResult(await store.readStream(sessionId, options)),
31
- acquire: async (sessionId) => wrapLease(await store.acquire(sessionId)),
31
+ list: () => prepared(async () => projectListResult(await store.listSummaries())),
32
+ read: (sessionId) => prepared(async () => projectSummary(await store.readSummary(sessionId))),
33
+ readStream: (sessionId, options) => prepared(async () => projectReadResult(await store.readStream(sessionId, options))),
34
+ acquire: (sessionId) => prepared(async () => wrapLease(await store.acquire(sessionId))),
32
35
  });
33
36
  }
34
37
 
@@ -111,3 +114,16 @@ function projectStatus(status) {
111
114
  incomplete: status.incomplete,
112
115
  });
113
116
  }
117
+
118
+ export {
119
+ createCaptainSessionStore as createSessionStore,
120
+ projectCaptainSessionStructure,
121
+ validateCaptainSessionExecutionProjection,
122
+ validateCaptainSessionStructuralProjection,
123
+ assertCaptainSessionExecutionCompatible,
124
+ } from './bin/session-store.js';
125
+ export {
126
+ validateSessionManifest,
127
+ validateSessionContext,
128
+ attachSessionHints,
129
+ } from './bin/portable-codec.js';
@@ -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
  },