@sublang/playbook 12.3.0 → 13.1.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 (53) 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.md +7 -0
  6. package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
  7. package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
  8. package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
  9. package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
  10. package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
  11. package/reference/sdlc/code.playbook/bin/run.js +62 -18
  12. package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
  13. package/reference/sdlc/code.playbook/bin/session-store.js +622 -65
  14. package/reference/sdlc/code.playbook/code.fsm.js +5 -5
  15. package/reference/sdlc/code.playbook/code.fsm.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.gears.md +5 -5
  17. package/reference/sdlc/code.playbook/code.playbook.d.ts +1 -1
  18. package/reference/sdlc/code.playbook/code.playbook.js +6 -16
  19. package/reference/sdlc/code.playbook/code.playbook.ts +7 -17
  20. package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
  21. package/reference/sdlc/code.playbook/playbook-captain.js +56 -14
  22. package/reference/sdlc/code.playbook/playbook-captain.ts +71 -21
  23. package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
  24. package/reference/sdlc/code.playbook/session-host.js +18 -0
  25. package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
  26. package/reference/sdlc/code.playbook/session-store.js +22 -6
  27. package/reference/sdlc/decide.md +2 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.js +1 -1
  29. package/reference/sdlc/decide.playbook/decide.fsm.ts +1 -1
  30. package/reference/sdlc/decide.playbook/decide.gears.md +2 -2
  31. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +1 -2
  32. package/reference/sdlc/decide.playbook/decide.playbook.js +8 -22
  33. package/reference/sdlc/decide.playbook/decide.playbook.ts +8 -25
  34. package/reference/sdlc/dev.md +21 -1
  35. package/reference/sdlc/dev.playbook/dev.fsm.js +10 -10
  36. package/reference/sdlc/dev.playbook/dev.fsm.ts +13 -13
  37. package/reference/sdlc/dev.playbook/dev.gears.md +15 -15
  38. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +1 -1
  39. package/reference/sdlc/dev.playbook/dev.playbook.js +7 -17
  40. package/reference/sdlc/dev.playbook/dev.playbook.ts +8 -20
  41. package/reference/sdlc/review.md +18 -0
  42. package/reference/sdlc/review.playbook/review.fsm.js +8 -8
  43. package/reference/sdlc/review.playbook/review.fsm.ts +8 -8
  44. package/reference/sdlc/review.playbook/review.gears.md +8 -8
  45. package/reference/sdlc/review.playbook/review.playbook.d.ts +1 -1
  46. package/reference/sdlc/review.playbook/review.playbook.js +5 -15
  47. package/reference/sdlc/review.playbook/review.playbook.ts +6 -16
  48. package/slc/link.md +12 -24
  49. package/src/runtime.d.ts +2 -0
  50. package/src/runtime.ts +2 -0
  51. package/src/xstate-playbook-runtime.d.ts +4 -2
  52. package/src/xstate-playbook-runtime.js +30 -26
  53. package/src/xstate-playbook-runtime.ts +40 -35
@@ -12,8 +12,8 @@ const APPENDED_PHASE_PROMPT = [
12
12
  'Coder is <coder-llm>.',
13
13
  ];
14
14
  const FIRST_PHASE_PROMPT = [
15
- '> <caller-input>',
16
- '> <run-results>',
15
+ '> Original request: <caller-input>',
16
+ '> Run results: <run-results>',
17
17
  '',
18
18
  'First determine whether the coding request starts a new coding intent or continues an existing IR with unfinished work.',
19
19
  'If the request may continue an existing IR but does not identify it unambiguously, ask Boss before changing files.',
@@ -33,9 +33,9 @@ const FIRST_PHASE_PROMPT = [
33
33
  ...APPENDED_PHASE_PROMPT,
34
34
  ].join('\n');
35
35
  const IR_TASK_PROMPT = [
36
- '> <caller-input>',
37
- '> <ir-number>',
38
- '> <run-results>',
36
+ '> Original request: <caller-input>',
37
+ '> IR number: <ir-number>',
38
+ '> Run results: <run-results>',
39
39
  '',
40
40
  'Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
41
41
  'Do not implement a later task in this phase.',
@@ -156,8 +156,8 @@ const APPENDED_PHASE_PROMPT = [
156
156
  ];
157
157
 
158
158
  const FIRST_PHASE_PROMPT = [
159
- '> <caller-input>',
160
- '> <run-results>',
159
+ '> Original request: <caller-input>',
160
+ '> Run results: <run-results>',
161
161
  '',
162
162
  'First determine whether the coding request starts a new coding intent or continues an existing IR with unfinished work.',
163
163
  'If the request may continue an existing IR but does not identify it unambiguously, ask Boss before changing files.',
@@ -178,9 +178,9 @@ const FIRST_PHASE_PROMPT = [
178
178
  ].join('\n');
179
179
 
180
180
  const IR_TASK_PROMPT = [
181
- '> <caller-input>',
182
- '> <ir-number>',
183
- '> <run-results>',
181
+ '> Original request: <caller-input>',
182
+ '> IR number: <ir-number>',
183
+ '> Run results: <run-results>',
184
184
  '',
185
185
  'Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
186
186
  'Do not implement a later task in this phase.',
@@ -13,8 +13,8 @@ Roles:
13
13
 
14
14
  When the first coding phase begins, Captain shall prompt Coder:
15
15
 
16
- > > <caller-input>
17
- > > <run-results>
16
+ > > Original request: <caller-input>
17
+ > > Run results: <run-results>
18
18
  >
19
19
  > First determine whether the coding request starts a new coding intent or continues an existing IR with unfinished work.
20
20
  > If the request may continue an existing IR but does not identify it unambiguously, ask Boss before changing files.
@@ -63,9 +63,9 @@ Workflow outcomes:
63
63
 
64
64
  When a later IR-task phase begins, Captain shall prompt Coder:
65
65
 
66
- > > <caller-input>
67
- > > <ir-number>
68
- > > <run-results>
66
+ > > Original request: <caller-input>
67
+ > > IR number: <ir-number>
68
+ > > Run results: <run-results>
69
69
  >
70
70
  > Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.
71
71
  > Do not implement a later task in this phase.
@@ -10,7 +10,7 @@ export type CodePlaybookHostCapabilities = PlaybookHostConstructionCapabilities
10
10
  * runtime text. The generic composer preserves the marker itself; CODE's
11
11
  * override additionally keeps a multiline value inside that quote.
12
12
  */
13
- declare function composePlayerPrompt(input: PlayerInput, promptIdentity: XStatePromptIdentity): string;
13
+ declare function composePlayerPrompt(input: PlayerInput, promptIdentity: XStatePromptIdentity, resuming?: boolean): string;
14
14
  export declare const _internal: {
15
15
  composePlayerPrompt: typeof composePlayerPrompt;
16
16
  VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string>;
@@ -10,11 +10,10 @@
10
10
  // the first phase decides among four semantic outcomes
11
11
  // Nested call: literal review target through the shared bridge
12
12
  // Compat: artifact schema 3 / runtime ABI 1
13
- import { createXStatePlaybookRuntime, snapshotJsonValue, } from '@sublang/playbook/xstate-runtime';
13
+ import { createXStatePlaybookRuntime, composePlayerContinuation, snapshotJsonValue, } from '@sublang/playbook/xstate-runtime';
14
14
  import { codingMachine, } from './code.fsm.js';
15
15
  const OPTION_KEYS = new Set(['runResults']);
16
16
  const PLACEHOLDER = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
17
- const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
18
17
  const VERBATIM_PAYLOAD_FIELDS = new Set([
19
18
  'coderOutput',
20
19
  ]);
@@ -52,11 +51,11 @@ function quoteContinuation(value) {
52
51
  * runtime text. The generic composer preserves the marker itself; CODE's
53
52
  * override additionally keeps a multiline value inside that quote.
54
53
  */
55
- function composePlayerPrompt(input, promptIdentity) {
54
+ function composePlayerPrompt(input, promptIdentity, resuming = false) {
56
55
  const fields = input;
57
56
  const template = input.prompt
58
57
  .split('\n')
59
- .filter((line) => !(line === '> <run-results>' && input.runResults.length === 0))
58
+ .filter((line) => !(line === '> Run results: <run-results>' && input.runResults.length === 0))
60
59
  .join('\n');
61
60
  const body = template.replace(PLACEHOLDER, (match, token, offset, source) => {
62
61
  const value = token === 'coder-llm'
@@ -66,18 +65,9 @@ function composePlayerPrompt(input, promptIdentity) {
66
65
  return match;
67
66
  const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
68
67
  const literal = source.slice(lineStart, offset);
69
- return literal === '> ' ? quoteContinuation(value) : value;
68
+ return literal.startsWith('> ') ? quoteContinuation(value) : value;
70
69
  });
71
- if (input.pendingBossQuestion === undefined ||
72
- input.bossReply === undefined) {
73
- return body;
74
- }
75
- return [
76
- CONTINUATION_PREAMBLE,
77
- `Boss question:\n${input.pendingBossQuestion.question}`,
78
- `Boss reply:\n${input.bossReply}`,
79
- body,
80
- ].join('\n\n');
70
+ return composePlayerContinuation(input, body, resuming);
81
71
  }
82
72
  export const _internal = {
83
73
  composePlayerPrompt,
@@ -177,7 +167,7 @@ const runtimeSpec = {
177
167
  },
178
168
  },
179
169
  },
180
- composePlayerPrompt: (input, promptIdentity) => composePlayerPrompt(input, promptIdentity),
170
+ composePlayerPrompt: (input, promptIdentity, resuming) => composePlayerPrompt(input, promptIdentity, resuming),
181
171
  verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
182
172
  controlContextFields: ['phase'],
183
173
  unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
@@ -13,6 +13,7 @@
13
13
 
14
14
  import {
15
15
  createXStatePlaybookRuntime,
16
+ composePlayerContinuation,
16
17
  snapshotJsonValue,
17
18
  type PlaybookPlayerInput,
18
19
  type XStatePlaybookRuntimeFactory,
@@ -85,8 +86,6 @@ export type CodePlaybookHostCapabilities =
85
86
 
86
87
  const OPTION_KEYS = new Set(['runResults']);
87
88
  const PLACEHOLDER = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
88
- const CONTINUATION_PREAMBLE =
89
- 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
90
89
 
91
90
  const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
92
91
  'coderOutput',
@@ -135,12 +134,13 @@ function quoteContinuation(value: string): string {
135
134
  function composePlayerPrompt(
136
135
  input: PlayerInput,
137
136
  promptIdentity: XStatePromptIdentity,
137
+ resuming = false,
138
138
  ): string {
139
139
  const fields = input as unknown as Record<string, unknown>;
140
140
  const template = input.prompt
141
141
  .split('\n')
142
142
  .filter(
143
- (line) => !(line === '> <run-results>' && input.runResults.length === 0),
143
+ (line) => !(line === '> Run results: <run-results>' && input.runResults.length === 0),
144
144
  )
145
145
  .join('\n');
146
146
  const body = template.replace(
@@ -153,21 +153,10 @@ function composePlayerPrompt(
153
153
  if (typeof value !== 'string') return match;
154
154
  const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
155
155
  const literal = source.slice(lineStart, offset);
156
- return literal === '> ' ? quoteContinuation(value) : value;
156
+ return literal.startsWith('> ') ? quoteContinuation(value) : value;
157
157
  },
158
158
  );
159
- if (
160
- input.pendingBossQuestion === undefined ||
161
- input.bossReply === undefined
162
- ) {
163
- return body;
164
- }
165
- return [
166
- CONTINUATION_PREAMBLE,
167
- `Boss question:\n${input.pendingBossQuestion.question}`,
168
- `Boss reply:\n${input.bossReply}`,
169
- body,
170
- ].join('\n\n');
159
+ return composePlayerContinuation(input, body, resuming);
171
160
  }
172
161
 
173
162
  export const _internal = {
@@ -273,7 +262,8 @@ const runtimeSpec = {
273
262
  composePlayerPrompt: (
274
263
  input: PlaybookPlayerInput,
275
264
  promptIdentity: XStatePromptIdentity,
276
- ) => composePlayerPrompt(input as PlayerInput, promptIdentity),
265
+ resuming?: boolean,
266
+ ) => composePlayerPrompt(input as PlayerInput, promptIdentity, resuming),
277
267
  verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
278
268
  controlContextFields: ['phase'],
279
269
  unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
@@ -35,6 +35,11 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
35
35
  type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, 'model' | 'effort' | 'fastMode'>>;
36
36
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
37
37
  export interface PlaybookCaptainDeps {
38
+ continuity?: {
39
+ beforeCall(participantId: string): Promise<void>;
40
+ acknowledged(participantId: string, token: string): void;
41
+ reset(participantId: string, reason: 'missing_hint' | 'rejected_hint'): Promise<void>;
42
+ };
38
43
  loadModule?: (specifier: string) => Promise<unknown>;
39
44
  createSessionId?: () => string;
40
45
  hostCapabilities?: Readonly<Record<string, PlaybookHostConstructionCapabilities>>;
@@ -949,6 +949,7 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
949
949
  'resumeToken',
950
950
  'finalText',
951
951
  'error',
952
+ 'errorCode',
952
953
  ]);
953
954
  const normalized = {};
954
955
  for (const key of Reflect.ownKeys(descriptors)) {
@@ -970,11 +971,16 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
970
971
  normalized[key] = descriptor.value;
971
972
  }
972
973
  const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
973
- rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
974
+ rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error', 'errorCode'], path);
974
975
  if (record.playerId !== expectedPlayerId) {
975
976
  throw new TypeError(`${path}.playerId does not match the requested player`);
976
977
  }
977
978
  snapshotInteger(record.turnId, `${path}.turnId`, 1);
979
+ if (record.errorCode !== undefined &&
980
+ (record.errorCode !== 'SESSION_RESUME_REJECTED' ||
981
+ record.status !== 'error' || record.resumeToken !== undefined)) {
982
+ throw new TypeError(`${path}.errorCode is not a definite resume rejection`);
983
+ }
978
984
  return validatePlayerResult({
979
985
  status: record.status,
980
986
  ...(record.resumeToken === undefined
@@ -1629,6 +1635,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1629
1635
  const createSessionId = deps.createSessionId ?? randomUUID;
1630
1636
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
1631
1637
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
1638
+ const continuity = deps.continuity;
1632
1639
  let pendingHostCapabilities = deps.hostCapabilities;
1633
1640
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
1634
1641
  // The returned shell must not retain the caller's aggregate dependency
@@ -2592,6 +2599,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2592
2599
  if (!ledger) {
2593
2600
  throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
2594
2601
  }
2602
+ if (options.freshPrompt !== undefined && typeof options.freshPrompt !== 'string') {
2603
+ throw new TypeError('player freshPrompt must be a string');
2604
+ }
2595
2605
  const expectedResume = ledger.resumeToken ?? false;
2596
2606
  if (options.resume !== expectedResume) {
2597
2607
  throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
@@ -2615,11 +2625,32 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2615
2625
  try {
2616
2626
  let rawResult;
2617
2627
  try {
2618
- rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
2619
- resume: options.resume,
2620
- settings,
2621
- })));
2622
- hostResolved = true;
2628
+ const call = async (resume) => {
2629
+ await continuity?.beforeCall(binding.playerId);
2630
+ signal.throwIfAborted();
2631
+ const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings })));
2632
+ hostResolved = true;
2633
+ return raw;
2634
+ };
2635
+ if (options.resume === false) {
2636
+ await continuity?.reset(binding.playerId, 'missing_hint');
2637
+ }
2638
+ rawResult = await call(options.resume);
2639
+ normalizeHostPlayerResult(rawResult, binding.playerId);
2640
+ if (typeof options.resume === 'string' && options.resume.length > 0 &&
2641
+ Object.getOwnPropertyDescriptor(rawResult, 'errorCode')?.value ===
2642
+ 'SESSION_RESUME_REJECTED') {
2643
+ if (playerTransactions.get(binding.playerId) !== calling ||
2644
+ calling.abandoned || signal.aborted || activeTurn !== admittedTurn ||
2645
+ frame.playerCallScope !== scope || !frames.includes(frame)) {
2646
+ signal.throwIfAborted();
2647
+ throw new Error(`${frameLabel(frame)} player rejection arrived after its runtime operation ended`);
2648
+ }
2649
+ delete ledger.resumeToken;
2650
+ await continuity?.reset(binding.playerId, 'rejected_hint');
2651
+ hostResolved = false;
2652
+ rawResult = await call(false);
2653
+ }
2623
2654
  }
2624
2655
  catch (error) {
2625
2656
  if (error instanceof AgentSettingsPreflightError) {
@@ -2914,6 +2945,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2914
2945
  delete ledger.resumeToken;
2915
2946
  else
2916
2947
  ledger.resumeToken = resumeToken;
2948
+ if (pending.status === 'ok' && resumeToken !== undefined) {
2949
+ continuity?.acknowledged(binding.playerId, resumeToken);
2950
+ }
2917
2951
  }
2918
2952
  finally {
2919
2953
  playerTransactions.delete(binding.playerId);
@@ -4109,7 +4143,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4109
4143
  };
4110
4144
  class CaptainContinuityError extends Error {
4111
4145
  constructor(cause) {
4112
- super('the session Captain conversation could not be resynchronized after one reseeded re-issue', { cause });
4146
+ super('the session Captain conversation lost continuity', { cause });
4113
4147
  this.name = 'CaptainContinuityError';
4114
4148
  }
4115
4149
  }
@@ -4121,6 +4155,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4121
4155
  }
4122
4156
  const rawDurableCall = async (context, prompt, resume, attempt) => {
4123
4157
  const queued = captainQueue.add(async () => {
4158
+ context.signal.throwIfAborted();
4159
+ await continuity?.beforeCall('captain');
4124
4160
  context.signal.throwIfAborted();
4125
4161
  attempt.providerBoundaryEntered = true;
4126
4162
  const result = await classifySettingsCall(() => context.callCaptain(prompt, {
@@ -4134,11 +4170,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4134
4170
  });
4135
4171
  return trackTurnCall(queued);
4136
4172
  };
4137
- // CAPTAIN-35: unsynchronized when the call throws, returns non-`ok`, or
4138
- // returns `ok` without a token. Exactly one re-issue on a fresh conversation
4139
- // seeded with the reseed digest plus the current ControlView digest. A
4140
- // conversation that is owed a reseed carries the digest on its very next
4141
- // call, so the turn after a failed reseed starts seeded rather than blank.
4173
+ // Only proven pre-execution rejection permits an immediate fresh call.
4174
+ // Other continuity loss leaves the journal reseed for the next Boss turn.
4142
4175
  const durableCall = async (context, compose) => {
4143
4176
  const startingConversation = conversation;
4144
4177
  const resume = startingConversation.kind === 'pinned'
@@ -4153,6 +4186,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4153
4186
  let result;
4154
4187
  let failure;
4155
4188
  try {
4189
+ if (resume === false)
4190
+ await continuity?.reset('captain', 'missing_hint');
4156
4191
  result = await rawDurableCall(context, compose(seedFirstCall
4157
4192
  ? { reseedDigest: reseedDigest() }
4158
4193
  : startingConversation.kind === 'needsCatchUp'
@@ -4187,9 +4222,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4187
4222
  const unsynchronized = failure !== undefined ||
4188
4223
  result === undefined ||
4189
4224
  result.status !== 'ok' ||
4190
- result.resumeToken === undefined;
4225
+ typeof result.resumeToken !== 'string' || result.resumeToken.trim().length === 0;
4191
4226
  if (!unsynchronized) {
4192
4227
  conversation = { kind: 'pinned', token: result.resumeToken };
4228
+ continuity?.acknowledged('captain', result.resumeToken);
4193
4229
  if (activeTurn) {
4194
4230
  activeTurn.captainSyncedJournalSeq = representedJournalSeq;
4195
4231
  }
@@ -4205,6 +4241,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4205
4241
  // stays `needsSeeding` until a call comes back with a token, so a reseed
4206
4242
  // that itself fails leaves the obligation standing for the next turn.
4207
4243
  conversation = { kind: 'needsSeeding' };
4244
+ if (typeof resume !== 'string' || resume.length === 0 ||
4245
+ result?.status !== 'error' || result.errorCode !== 'SESSION_RESUME_REJECTED') {
4246
+ throw markControlFailure(new CaptainContinuityError(failure ?? result?.error ?? 'callCaptain did not establish continuation'));
4247
+ }
4248
+ await continuity?.reset('captain', 'rejected_hint');
4208
4249
  const recap = reseedDigest();
4209
4250
  let reissued;
4210
4251
  const reissueAttempt = { providerBoundaryEntered: false };
@@ -4221,11 +4262,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4221
4262
  }
4222
4263
  throw markControlFailure(new CaptainContinuityError(error));
4223
4264
  }
4224
- if (reissued.status !== 'ok' || reissued.resumeToken === undefined) {
4265
+ if (reissued.status !== 'ok' || typeof reissued.resumeToken !== 'string' || reissued.resumeToken.trim().length === 0) {
4225
4266
  throw markControlFailure(new CaptainContinuityError(reissued.error ??
4226
4267
  `callCaptain status "${reissued.status}" without a resume token`));
4227
4268
  }
4228
4269
  conversation = { kind: 'pinned', token: reissued.resumeToken };
4270
+ continuity?.acknowledged('captain', reissued.resumeToken);
4229
4271
  if (activeTurn)
4230
4272
  activeTurn.captainSyncedJournalSeq = journalSeq;
4231
4273
  return {
@@ -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<
@@ -1819,6 +1824,7 @@ function normalizeHostPlayerResult(
1819
1824
  'resumeToken',
1820
1825
  'finalText',
1821
1826
  'error',
1827
+ 'errorCode',
1822
1828
  ]);
1823
1829
  const normalized: Record<string, unknown> = {};
1824
1830
  for (const key of Reflect.ownKeys(descriptors)) {
@@ -1841,13 +1847,18 @@ function normalizeHostPlayerResult(
1841
1847
  const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
1842
1848
  rejectSnapshotKeys(
1843
1849
  record,
1844
- ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
1850
+ ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error', 'errorCode'],
1845
1851
  path,
1846
1852
  );
1847
1853
  if (record.playerId !== expectedPlayerId) {
1848
1854
  throw new TypeError(`${path}.playerId does not match the requested player`);
1849
1855
  }
1850
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
+ }
1851
1862
  return validatePlayerResult(
1852
1863
  {
1853
1864
  status: record.status,
@@ -2988,6 +2999,7 @@ export function createPlaybookCaptainShell(
2988
2999
  PlaybookCaptainDeps['createCaptainRuntime']
2989
3000
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2990
3001
  const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
3002
+ const continuity = deps.continuity;
2991
3003
  let pendingHostCapabilities = deps.hostCapabilities;
2992
3004
  let currentEffectLedger = () => emptyPlaybookEffectLedger();
2993
3005
  // The returned shell must not retain the caller's aggregate dependency
@@ -4349,6 +4361,9 @@ export function createPlaybookCaptainShell(
4349
4361
  `${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
4350
4362
  );
4351
4363
  }
4364
+ if (options.freshPrompt !== undefined && typeof options.freshPrompt !== 'string') {
4365
+ throw new TypeError('player freshPrompt must be a string');
4366
+ }
4352
4367
  const expectedResume = ledger.resumeToken ?? false;
4353
4368
  if (options.resume !== expectedResume) {
4354
4369
  throw new Error(
@@ -4376,16 +4391,37 @@ export function createPlaybookCaptainShell(
4376
4391
  try {
4377
4392
  let rawResult: unknown;
4378
4393
  try {
4379
- rawResult = await trackHostCall(
4380
- frame,
4381
- classifySettingsCall(() =>
4382
- context.callPlayer(binding.playerId, prompt, {
4383
- resume: options.resume,
4384
- settings,
4385
- }),
4386
- ),
4387
- );
4388
- hostResolved = true;
4394
+ const call = async (resume: string | false): Promise<unknown> => {
4395
+ await continuity?.beforeCall(binding.playerId);
4396
+ signal.throwIfAborted();
4397
+ const raw = await trackHostCall(
4398
+ frame,
4399
+ classifySettingsCall(() =>
4400
+ context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings }),
4401
+ ),
4402
+ );
4403
+ hostResolved = true;
4404
+ return raw;
4405
+ };
4406
+ if (options.resume === false) {
4407
+ await continuity?.reset(binding.playerId, 'missing_hint');
4408
+ }
4409
+ rawResult = await call(options.resume);
4410
+ normalizeHostPlayerResult(rawResult, binding.playerId);
4411
+ if (typeof options.resume === 'string' && options.resume.length > 0 &&
4412
+ Object.getOwnPropertyDescriptor(rawResult as object, 'errorCode')?.value ===
4413
+ 'SESSION_RESUME_REJECTED') {
4414
+ if (playerTransactions.get(binding.playerId) !== calling ||
4415
+ calling.abandoned || signal.aborted || activeTurn !== admittedTurn ||
4416
+ frame.playerCallScope !== scope || !frames.includes(frame)) {
4417
+ signal.throwIfAborted();
4418
+ throw new Error(`${frameLabel(frame)} player rejection arrived after its runtime operation ended`);
4419
+ }
4420
+ delete ledger.resumeToken;
4421
+ await continuity?.reset(binding.playerId, 'rejected_hint');
4422
+ hostResolved = false;
4423
+ rawResult = await call(false);
4424
+ }
4389
4425
  } catch (error) {
4390
4426
  if (error instanceof AgentSettingsPreflightError) {
4391
4427
  if (
@@ -4739,6 +4775,9 @@ export function createPlaybookCaptainShell(
4739
4775
  try {
4740
4776
  if (resumeToken === undefined) delete ledger.resumeToken;
4741
4777
  else ledger.resumeToken = resumeToken;
4778
+ if (pending.status === 'ok' && resumeToken !== undefined) {
4779
+ continuity?.acknowledged(binding.playerId, resumeToken);
4780
+ }
4742
4781
  } finally {
4743
4782
  playerTransactions.delete(binding.playerId);
4744
4783
  }
@@ -6161,7 +6200,7 @@ export function createPlaybookCaptainShell(
6161
6200
  class CaptainContinuityError extends Error {
6162
6201
  constructor(cause: unknown) {
6163
6202
  super(
6164
- 'the session Captain conversation could not be resynchronized after one reseeded re-issue',
6203
+ 'the session Captain conversation lost continuity',
6165
6204
  { cause },
6166
6205
  );
6167
6206
  this.name = 'CaptainContinuityError';
@@ -6185,8 +6224,11 @@ export function createPlaybookCaptainShell(
6185
6224
  finalText?: string;
6186
6225
  resumeToken?: string;
6187
6226
  error?: string;
6227
+ errorCode?: 'SESSION_RESUME_REJECTED';
6188
6228
  }> => {
6189
6229
  const queued = captainQueue.add(async () => {
6230
+ context.signal.throwIfAborted();
6231
+ await continuity?.beforeCall('captain');
6190
6232
  context.signal.throwIfAborted();
6191
6233
  attempt.providerBoundaryEntered = true;
6192
6234
  const result = await classifySettingsCall(() =>
@@ -6205,14 +6247,12 @@ export function createPlaybookCaptainShell(
6205
6247
  finalText?: string;
6206
6248
  resumeToken?: string;
6207
6249
  error?: string;
6250
+ errorCode?: 'SESSION_RESUME_REJECTED';
6208
6251
  }>;
6209
6252
  };
6210
6253
 
6211
- // CAPTAIN-35: unsynchronized when the call throws, returns non-`ok`, or
6212
- // returns `ok` without a token. Exactly one re-issue on a fresh conversation
6213
- // seeded with the reseed digest plus the current ControlView digest. A
6214
- // conversation that is owed a reseed carries the digest on its very next
6215
- // call, so the turn after a failed reseed starts seeded rather than blank.
6254
+ // Only proven pre-execution rejection permits an immediate fresh call.
6255
+ // Other continuity loss leaves the journal reseed for the next Boss turn.
6216
6256
  const durableCall = async (
6217
6257
  context: CaptainContext,
6218
6258
  compose: (options: { reseedDigest?: string }) => string,
@@ -6229,10 +6269,11 @@ export function createPlaybookCaptainShell(
6229
6269
  const representedJournalSeq = journalSeq;
6230
6270
  const firstAttempt = { providerBoundaryEntered: false };
6231
6271
  let result:
6232
- | { status: string; finalText?: string; resumeToken?: string; error?: string }
6272
+ | { status: string; finalText?: string; resumeToken?: string; error?: string; errorCode?: 'SESSION_RESUME_REJECTED' }
6233
6273
  | undefined;
6234
6274
  let failure: unknown;
6235
6275
  try {
6276
+ if (resume === false) await continuity?.reset('captain', 'missing_hint');
6236
6277
  result = await rawDurableCall(
6237
6278
  context,
6238
6279
  compose(
@@ -6279,9 +6320,10 @@ export function createPlaybookCaptainShell(
6279
6320
  failure !== undefined ||
6280
6321
  result === undefined ||
6281
6322
  result.status !== 'ok' ||
6282
- result.resumeToken === undefined;
6323
+ typeof result.resumeToken !== 'string' || result.resumeToken.trim().length === 0;
6283
6324
  if (!unsynchronized) {
6284
6325
  conversation = { kind: 'pinned', token: result!.resumeToken! };
6326
+ continuity?.acknowledged('captain', result!.resumeToken!);
6285
6327
  if (activeTurn) {
6286
6328
  activeTurn.captainSyncedJournalSeq = representedJournalSeq;
6287
6329
  }
@@ -6297,9 +6339,16 @@ export function createPlaybookCaptainShell(
6297
6339
  // stays `needsSeeding` until a call comes back with a token, so a reseed
6298
6340
  // that itself fails leaves the obligation standing for the next turn.
6299
6341
  conversation = { kind: 'needsSeeding' };
6342
+ if (typeof resume !== 'string' || resume.length === 0 ||
6343
+ result?.status !== 'error' || result.errorCode !== 'SESSION_RESUME_REJECTED') {
6344
+ throw markControlFailure(new CaptainContinuityError(
6345
+ failure ?? result?.error ?? 'callCaptain did not establish continuation',
6346
+ ));
6347
+ }
6348
+ await continuity?.reset('captain', 'rejected_hint');
6300
6349
  const recap = reseedDigest();
6301
6350
  let reissued:
6302
- | { status: string; finalText?: string; resumeToken?: string; error?: string }
6351
+ | { status: string; finalText?: string; resumeToken?: string; error?: string; errorCode?: 'SESSION_RESUME_REJECTED' }
6303
6352
  | undefined;
6304
6353
  const reissueAttempt = { providerBoundaryEntered: false };
6305
6354
  try {
@@ -6319,7 +6368,7 @@ export function createPlaybookCaptainShell(
6319
6368
  }
6320
6369
  throw markControlFailure(new CaptainContinuityError(error));
6321
6370
  }
6322
- if (reissued.status !== 'ok' || reissued.resumeToken === undefined) {
6371
+ if (reissued.status !== 'ok' || typeof reissued.resumeToken !== 'string' || reissued.resumeToken.trim().length === 0) {
6323
6372
  throw markControlFailure(
6324
6373
  new CaptainContinuityError(
6325
6374
  reissued.error ??
@@ -6328,6 +6377,7 @@ export function createPlaybookCaptainShell(
6328
6377
  );
6329
6378
  }
6330
6379
  conversation = { kind: 'pinned', token: reissued.resumeToken };
6380
+ continuity?.acknowledged('captain', reissued.resumeToken);
6331
6381
  if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
6332
6382
  return {
6333
6383
  ...(reissued.finalText !== undefined