@sublang/playbook 9.0.0 → 11.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 (71) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +109 -21
  3. package/docs/configuration.md +89 -40
  4. package/docs/embedding.md +126 -12
  5. package/package.json +14 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  14. package/reference/sdlc/code.md +35 -16
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +228 -23
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +611 -221
  17. package/reference/sdlc/code.playbook/bin/playbook.js +304 -178
  18. package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
  19. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  20. package/reference/sdlc/code.playbook/bin/run.js +669 -215
  21. package/reference/sdlc/code.playbook/bin/session-store.js +4546 -502
  22. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  23. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  24. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  25. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  26. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  27. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  28. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  29. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  31. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  32. package/reference/sdlc/code.playbook/playbook-captain.d.ts +103 -8
  33. package/reference/sdlc/code.playbook/playbook-captain.js +1871 -75
  34. package/reference/sdlc/code.playbook/playbook-captain.ts +2801 -102
  35. package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
  36. package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
  37. package/reference/sdlc/code.playbook/session-store.js +113 -0
  38. package/reference/sdlc/decide.md +24 -16
  39. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  40. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  41. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  42. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  43. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  44. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  45. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  46. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  47. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  48. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  49. package/reference/sdlc/review.md +36 -18
  50. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  51. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  52. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  53. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  54. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  55. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  56. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  57. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  58. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  59. package/slc/gears2fsm.md +6 -5
  60. package/slc/link.md +544 -41
  61. package/src/accepted-outcome.d.ts +18 -0
  62. package/src/accepted-outcome.js +94 -0
  63. package/src/accepted-outcome.ts +140 -0
  64. package/src/runtime.d.ts +164 -3
  65. package/src/runtime.ts +213 -2
  66. package/src/xstate-playbook-runtime.d.ts +149 -10
  67. package/src/xstate-playbook-runtime.js +2569 -270
  68. package/src/xstate-playbook-runtime.ts +4133 -490
  69. package/src/xstate-runtime.d.ts +59 -1
  70. package/src/xstate-runtime.js +866 -7
  71. package/src/xstate-runtime.ts +1397 -7
@@ -6,51 +6,75 @@
6
6
  // in tmux. The core below uses cligent's ordinary tmux-play runtime without a
7
7
  // presenter; it does not construct a registry runtime or PlaybookPorts itself.
8
8
 
9
- import { randomUUID } from 'node:crypto';
10
- import { homedir } from 'node:os';
11
- import { resolve } from 'node:path';
12
- import { isDeepStrictEqual } from 'node:util';
13
- import { createTmuxPlayRuntime } from '@sublang/cligent/tmux-play';
14
- import { createPlaybookCaptainShell } from '../playbook-captain.js';
9
+ import { randomUUID } from "node:crypto";
10
+ import { homedir } from "node:os";
11
+ import { resolve } from "node:path";
12
+ import { isDeepStrictEqual } from "node:util";
13
+ import { assertFastModeSupported } from "@sublang/cligent";
14
+ import { createTmuxPlayRuntime } from "@sublang/cligent/tmux-play";
15
+ import {
16
+ assertPlaybookEffectLedger,
17
+ emptyPlaybookEffectLedger,
18
+ } from "../../../../src/xstate-runtime.js";
19
+ import {
20
+ assertPlaybookCaptainShellSnapshot,
21
+ createPlaybookCaptainShell,
22
+ } from "../playbook-captain.js";
15
23
  import {
16
24
  adapterSdkFailureLines,
17
25
  checkAdapterSdks,
18
26
  mappedSdksFor,
19
27
  probeAdapterSdk,
20
- } from './adapter-sdk.js';
28
+ } from "./adapter-sdk.js";
21
29
  import {
22
30
  checkReadiness,
31
+ invalidRegistryEntryReason,
23
32
  loadLaunchPlan,
24
33
  projectHostAgent,
34
+ resolveLaunchSessionsDir,
35
+ relocateLegacyUserConfig,
36
+ resolveLegacyUserConfigPath,
25
37
  resolveUserConfigPath,
26
- } from './launch-config.js';
27
- import { prepareConfiguredRegistries } from './provision.js';
38
+ snapshotRegistryEntry,
39
+ } from "./launch-config.js";
40
+ import { prepareConfiguredRegistries } from "./provision.js";
41
+ import {
42
+ createReplayRecordObserver,
43
+ replayIncompleteMessage,
44
+ } from "./replay-observer.js";
45
+ import {
46
+ createRepositoryEffectCapabilities,
47
+ refreshRepositoryEffectCapabilities,
48
+ recoverIncompleteRepositoryEffects,
49
+ } from "./repository-effects.js";
28
50
  import {
29
51
  assertCaptainSessionExecutionCompatible,
52
+ assertCaptainSessionsDirectoryUsable,
30
53
  captainSessionSelectedMembers,
31
54
  createCaptainSessionStore,
32
55
  projectCaptainSessionStructure,
33
56
  SESSION_ID_PATTERN,
34
57
  validateCaptainSessionExecutionProjection,
35
58
  validateCaptainSessionRecord,
36
- } from './session-store.js';
59
+ } from "./session-store.js";
37
60
 
38
61
  const EXIT = { ok: 0, argument: 1, turn: 2 };
39
62
  const UUID_PATTERN = SESSION_ID_PATTERN;
63
+ const HEADLESS_REPLAY_CHANNELS = new WeakMap();
40
64
  class HeadlessHostSetupError extends Error {
41
65
  constructor(cause) {
42
66
  super(message(cause));
43
- this.name = 'HeadlessHostSetupError';
67
+ this.name = "HeadlessHostSetupError";
44
68
  this.cause = cause;
45
69
  }
46
70
  }
47
71
  const RETIRED_FLAGS = new Set([
48
- '--player',
49
- '--captain',
50
- '--option',
51
- '--cwd',
52
- '--last',
53
- '--config',
72
+ "--player",
73
+ "--captain",
74
+ "--option",
75
+ "--cwd",
76
+ "--last",
77
+ "--config",
54
78
  ]);
55
79
 
56
80
  export async function runPlaybookRun(options = {}) {
@@ -67,15 +91,25 @@ export async function runPlaybookRun(options = {}) {
67
91
  }
68
92
  if (args.help) {
69
93
  const env = options.env ?? process.env;
70
- const home = options.homeDir ?? env.HOME ?? homedir();
94
+ const home =
95
+ options.homeDir ??
96
+ (typeof env.HOME === "string" && env.HOME.trim().length > 0
97
+ ? env.HOME
98
+ : homedir());
71
99
  const userConfigPath =
72
100
  options.userConfigPath ?? resolveUserConfigPath(env, home);
101
+ // PBCLI-17: help resolves the path but writes nothing, so it neither
102
+ // seeds nor relocates.
73
103
  await writeStream(stdout, runHelpText(userConfigPath));
74
104
  return { code: EXIT.ok };
75
105
  }
76
106
 
77
107
  const env = options.env ?? process.env;
78
- const home = options.homeDir ?? env.HOME ?? homedir();
108
+ const home =
109
+ options.homeDir ??
110
+ (typeof env.HOME === "string" && env.HOME.trim().length > 0
111
+ ? env.HOME
112
+ : homedir());
79
113
  const recovering = args.retryUncertain || args.discardUncertain;
80
114
  const continuing = args.continue || args.sessionId !== undefined;
81
115
  let input = args.input;
@@ -90,6 +124,47 @@ export async function runPlaybookRun(options = {}) {
90
124
  input = resolvedInput.input;
91
125
  }
92
126
 
127
+ const userConfigPath =
128
+ options.userConfigPath ?? resolveUserConfigPath(env, home);
129
+ // DR-043: move a pre-relocation config to the canonical path before any
130
+ // read, seed, or plan work observes its absence.
131
+ if (options.userConfigPath === undefined) {
132
+ try {
133
+ relocateLegacyUserConfig(
134
+ userConfigPath,
135
+ resolveLegacyUserConfigPath(env, home),
136
+ (line) => stderr.write(line),
137
+ );
138
+ } catch (error) {
139
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
140
+ return { code: EXIT.argument };
141
+ }
142
+ }
143
+ const bootstrapConfigNotices = [];
144
+ let resolvedSessionsDir;
145
+ if (options.sessionStore === undefined) {
146
+ try {
147
+ resolvedSessionsDir = resolveLaunchSessionsDir({
148
+ userConfigPath,
149
+ overlayPaths: args.withPaths,
150
+ env,
151
+ homeDir: home,
152
+ ...(options.sessionsDir !== undefined
153
+ ? { sessionsDir: options.sessionsDir }
154
+ : {}),
155
+ preparePrimary: !continuing,
156
+ onNotice: (line) => bootstrapConfigNotices.push(line),
157
+ });
158
+ await assertCaptainSessionsDirectoryUsable(resolvedSessionsDir);
159
+ } catch (error) {
160
+ for (const line of bootstrapConfigNotices) {
161
+ await writeStream(stderr, line);
162
+ }
163
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
164
+ return { code: EXIT.argument };
165
+ }
166
+ }
167
+
93
168
  let store;
94
169
  try {
95
170
  store =
@@ -97,7 +172,7 @@ export async function runPlaybookRun(options = {}) {
97
172
  createCaptainSessionStore({
98
173
  env,
99
174
  homeDir: home,
100
- ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
175
+ sessionsDir: resolvedSessionsDir,
101
176
  ...(options.now ? { now: options.now } : {}),
102
177
  ...(options.createSessionTempId
103
178
  ? { createTempId: options.createSessionTempId }
@@ -114,6 +189,7 @@ export async function runPlaybookRun(options = {}) {
114
189
  let config;
115
190
  let cwd;
116
191
  let restoreSnapshot;
192
+ let replayChannel;
117
193
  const loadModule = memoizedModuleLoader(
118
194
  options.loadModule ?? ((specifier) => import(specifier)),
119
195
  );
@@ -122,26 +198,45 @@ export async function runPlaybookRun(options = {}) {
122
198
  if (continuing) {
123
199
  try {
124
200
  if (args.sessionId === undefined) {
201
+ const invokingCwd = resolve(options.cwd ?? process.cwd());
125
202
  const selected = validateCaptainSessionRecord(
126
203
  await awaitWithAbort(
127
204
  store.latest({
128
- onLegacyRecord: ({ sessionId: legacyId, path }) =>
129
- writeStream(
205
+ preferredCwd: invokingCwd,
206
+ onLegacyRecord: (record) =>
207
+ reportSkippedCaptainSession(
130
208
  stderr,
131
- `playbook run: skipping legacy Captain session ${JSON.stringify(legacyId)} at ${JSON.stringify(path)} because schema 2 has incompatible player identity; move it outside the sessions directory or remove it to silence this warning\n`,
209
+ "playbook run",
210
+ "legacy",
211
+ record,
132
212
  ),
133
213
  }),
134
214
  options.signal,
135
215
  ),
136
216
  );
217
+ if (selected.cwd !== invokingCwd) {
218
+ await writeStream(
219
+ stderr,
220
+ `playbook run: no same-directory Captain session exists for invoking working directory ${JSON.stringify(invokingCwd)}; selecting globally newest Captain session ${JSON.stringify(selected.sessionId)} with stored working directory ${JSON.stringify(selected.cwd)}\n`,
221
+ );
222
+ }
137
223
  sessionId = selected.sessionId;
138
224
  } else {
139
225
  sessionId = args.sessionId;
140
226
  }
141
227
  throwIfAborted(options.signal);
142
228
  lease = await store.acquire(sessionId);
229
+ replayChannel = createHeadlessReplayChannel({
230
+ lease,
231
+ sessionId,
232
+ stderr,
233
+ });
234
+ await reportHeadlessReplay(replayChannel);
143
235
  throwIfAborted(options.signal);
144
- const authoritative = await lease.read();
236
+ const authoritative =
237
+ typeof lease.recoverUnresolvedEffectAbandonment === "function"
238
+ ? await lease.recoverUnresolvedEffectAbandonment()
239
+ : await lease.read();
145
240
  throwIfAborted(options.signal);
146
241
  if (authoritative === undefined) {
147
242
  throw new Error(
@@ -163,7 +258,7 @@ export async function runPlaybookRun(options = {}) {
163
258
  return { code: EXIT.argument };
164
259
  }
165
260
 
166
- if (priorRecord.state === 'uncertain') {
261
+ if (priorRecord.state === "uncertain") {
167
262
  if (args.discardUncertain) {
168
263
  try {
169
264
  throwIfAborted(options.signal);
@@ -244,10 +339,8 @@ export async function runPlaybookRun(options = {}) {
244
339
  }
245
340
 
246
341
  if (!continuing || !args.retryUncertain) {
247
- const userConfigPath =
248
- options.userConfigPath ?? resolveUserConfigPath(env, home);
249
342
  let plan;
250
- const configNotices = [];
343
+ const configNotices = [...bootstrapConfigNotices];
251
344
  try {
252
345
  throwIfAborted(options.signal);
253
346
  plan = await loadLaunchPlan({
@@ -289,7 +382,7 @@ export async function runPlaybookRun(options = {}) {
289
382
  );
290
383
  } else {
291
384
  sessionId = (options.createLogicalSessionId ?? randomUUID)();
292
- if (typeof sessionId !== 'string' || !UUID_PATTERN.test(sessionId)) {
385
+ if (typeof sessionId !== "string" || !UUID_PATTERN.test(sessionId)) {
293
386
  throw new Error(
294
387
  `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
295
388
  );
@@ -392,6 +485,12 @@ export async function runPlaybookRun(options = {}) {
392
485
  try {
393
486
  throwIfAborted(options.signal);
394
487
  lease = await store.acquire(sessionId);
488
+ replayChannel = createHeadlessReplayChannel({
489
+ lease,
490
+ sessionId,
491
+ stderr,
492
+ });
493
+ await reportHeadlessReplay(replayChannel);
395
494
  throwIfAborted(options.signal);
396
495
  } catch (error) {
397
496
  const releaseError = await releaseLease(lease);
@@ -424,12 +523,15 @@ export async function runPlaybookRun(options = {}) {
424
523
  }
425
524
 
426
525
  let settled;
526
+ let freshLaunchRecord;
427
527
  try {
428
528
  settled = await driveHeadlessCaptainTurn({
429
529
  config,
430
530
  input,
431
531
  sessionId,
432
532
  cwd,
533
+ sessionLease: lease,
534
+ replayObserver: replayChannel?.observer,
433
535
  loadModule,
434
536
  stderr,
435
537
  verbose: args.verbose,
@@ -445,11 +547,56 @@ export async function runPlaybookRun(options = {}) {
445
547
  ...(options.createHostRuntime
446
548
  ? { createHostRuntime: options.createHostRuntime }
447
549
  : {}),
448
- ...(restoreSnapshot !== undefined
449
- ? { restoreSnapshot }
550
+ ...(options.createEffectLedgerWriteAhead
551
+ ? {
552
+ createEffectLedgerWriteAhead: options.createEffectLedgerWriteAhead,
553
+ }
450
554
  : {}),
555
+ ...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
556
+ ...(args.retryUncertain ? { reconcileUncertainTurnReplay: true } : {}),
451
557
  ...(options.signal ? { signal: options.signal } : {}),
452
- beforeBossTurn: async (baselineSnapshot) => {
558
+ beforeBossTurn: async (
559
+ baselineSnapshot,
560
+ shell,
561
+ reconcileRepositoryEffects,
562
+ ) => {
563
+ throwIfAborted(options.signal);
564
+ const installForLaunch =
565
+ options.installRetainedGenerationsForLaunch ??
566
+ installRetainedGenerationsForLaunch;
567
+ await installForLaunch({
568
+ lease,
569
+ shell,
570
+ ...(priorRecord === undefined
571
+ ? {
572
+ freshBoundary: {
573
+ cwd,
574
+ structuralProjection: projectCaptainSessionStructure(config),
575
+ executionProjection: config,
576
+ snapshot: baselineSnapshot,
577
+ },
578
+ onFreshRecord(record) {
579
+ freshLaunchRecord = record;
580
+ },
581
+ onLegacyRecord: (record) =>
582
+ reportSkippedCaptainSession(
583
+ stderr,
584
+ "playbook run",
585
+ "legacy",
586
+ record,
587
+ ),
588
+ onInvalidRecord: (record) =>
589
+ reportSkippedCaptainSession(
590
+ stderr,
591
+ "playbook run",
592
+ "invalid",
593
+ record,
594
+ ),
595
+ }
596
+ : {}),
597
+ retainedGenerations: priorRecord?.retainedGenerations ?? {},
598
+ reconcileRepositoryEffects,
599
+ });
453
600
  throwIfAborted(options.signal);
454
601
  return args.retryUncertain
455
602
  ? lease.beginRetry({
@@ -460,22 +607,26 @@ export async function runPlaybookRun(options = {}) {
460
607
  input,
461
608
  attemptId,
462
609
  attemptedExecutionProjection: config,
463
- ...(priorRecord === undefined
464
- ? {
465
- fresh: {
466
- cwd,
467
- structuralProjection:
468
- projectCaptainSessionStructure(config),
469
- snapshot: baselineSnapshot,
470
- },
471
- }
472
- : {}),
473
610
  });
474
611
  },
475
612
  assertBeforeBossTurn: () => lease.assertOwner(),
476
613
  });
614
+ await reportHeadlessReplay(replayChannel);
477
615
  } catch (error) {
616
+ await reportHeadlessReplay(replayChannel);
478
617
  const cleanupIncomplete = isCaptainSessionHostCleanupIncomplete(error);
618
+ let abandonmentError;
619
+ if (
620
+ !cleanupIncomplete &&
621
+ freshLaunchRecord !== undefined &&
622
+ Object.keys(freshLaunchRecord.retainedGenerations ?? {}).length === 0
623
+ ) {
624
+ try {
625
+ await lease.abandonFreshSettled({ expected: freshLaunchRecord });
626
+ } catch (cause) {
627
+ abandonmentError = cause;
628
+ }
629
+ }
479
630
  const releaseError = cleanupIncomplete
480
631
  ? undefined
481
632
  : await releaseLease(lease);
@@ -483,7 +634,7 @@ export async function runPlaybookRun(options = {}) {
483
634
  if (cleanupIncomplete) {
484
635
  await writeStream(
485
636
  stderr,
486
- 'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
637
+ "playbook run: writer lease retained until process exit because host cleanup was incomplete\n",
487
638
  );
488
639
  }
489
640
  if (releaseError !== undefined) {
@@ -492,9 +643,17 @@ export async function runPlaybookRun(options = {}) {
492
643
  `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
493
644
  );
494
645
  }
646
+ if (abandonmentError !== undefined) {
647
+ await writeStream(
648
+ stderr,
649
+ `playbook run: cannot retract unused fresh Captain session: ${message(abandonmentError)}\n`,
650
+ );
651
+ }
495
652
  return {
496
653
  code:
497
- error instanceof HeadlessHostSetupError && releaseError === undefined
654
+ error instanceof HeadlessHostSetupError &&
655
+ abandonmentError === undefined &&
656
+ releaseError === undefined
498
657
  ? EXIT.argument
499
658
  : EXIT.turn,
500
659
  };
@@ -506,8 +665,12 @@ export async function runPlaybookRun(options = {}) {
506
665
  durableRecord = await lease.settle({
507
666
  attemptId: settled.uncertainRecord.uncertain.attemptId,
508
667
  snapshot: settled.snapshot,
668
+ unresolvedEffects: settled.unresolvedEffects,
669
+ retentionUpdates: settled.retentionUpdates,
509
670
  });
671
+ await reportHeadlessReplay(replayChannel);
510
672
  } catch (error) {
673
+ await reportHeadlessReplay(replayChannel);
511
674
  let cleanupError;
512
675
  try {
513
676
  await settled.dispose();
@@ -523,13 +686,10 @@ export async function runPlaybookRun(options = {}) {
523
686
  [error, cleanupError],
524
687
  `Captain session settlement failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
525
688
  );
689
+ await writeStream(stderr, `playbook run: ${message(cleanupFailure)}\n`);
526
690
  await writeStream(
527
691
  stderr,
528
- `playbook run: ${message(cleanupFailure)}\n`,
529
- );
530
- await writeStream(
531
- stderr,
532
- 'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
692
+ "playbook run: writer lease retained until process exit because host cleanup was incomplete\n",
533
693
  );
534
694
  return { code: EXIT.turn };
535
695
  }
@@ -555,7 +715,7 @@ export async function runPlaybookRun(options = {}) {
555
715
  if (options.signal?.aborted) {
556
716
  await writeStream(
557
717
  stderr,
558
- 'playbook run: Captain turn was interrupted; reply withheld\n',
718
+ "playbook run: Captain turn was interrupted; reply withheld\n",
559
719
  );
560
720
  return { code: EXIT.turn, sessionId, record: durableRecord };
561
721
  }
@@ -593,6 +753,8 @@ export async function driveHeadlessCaptainTurn({
593
753
  input,
594
754
  sessionId,
595
755
  cwd,
756
+ sessionLease,
757
+ replayObserver,
596
758
  loadModule,
597
759
  stderr,
598
760
  verbose = false,
@@ -600,7 +762,9 @@ export async function driveHeadlessCaptainTurn({
600
762
  createCaptainRuntime,
601
763
  createCaptainSessionId,
602
764
  createHostRuntime = createTmuxPlayRuntime,
765
+ createEffectLedgerWriteAhead,
603
766
  restoreSnapshot,
767
+ reconcileUncertainTurnReplay = false,
604
768
  beforeBossTurn,
605
769
  assertBeforeBossTurn,
606
770
  signal,
@@ -616,41 +780,53 @@ export async function driveHeadlessCaptainTurn({
616
780
  config,
617
781
  sessionId,
618
782
  cwd,
783
+ sessionLease,
619
784
  loadModule,
620
785
  observers: [
621
786
  {
622
787
  async onRecord(record) {
623
- if (record.type === 'captain_reply') {
788
+ if (record.type === "captain_reply") {
624
789
  replies.push(record.text);
625
- } else if (record.type === 'captain_status') {
790
+ } else if (record.type === "captain_status") {
626
791
  await writeStream(stderr, `${record.message}\n`);
627
- } else if (verbose && record.type === 'captain_telemetry') {
792
+ } else if (verbose && record.type === "captain_telemetry") {
628
793
  await writeStream(stderr, `\u00b7 ${record.topic}\n`);
629
794
  }
630
795
  },
631
796
  },
797
+ ...(replayObserver === undefined ? [] : [replayObserver]),
632
798
  ],
633
799
  ...(signal ? { signal } : {}),
634
800
  ...(adapterImports ? { adapterImports } : {}),
635
801
  ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
636
802
  ...(createCaptainSessionId ? { createCaptainSessionId } : {}),
637
803
  createHostRuntime,
804
+ ...(createEffectLedgerWriteAhead
805
+ ? { createEffectLedgerWriteAhead }
806
+ : {}),
638
807
  ...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
808
+ ...(reconcileUncertainTurnReplay
809
+ ? { reconcileUncertainTurnReplay: true }
810
+ : {}),
639
811
  });
640
812
  ({ shell, host, snapshot: baselineSnapshot } = created);
641
- uncertainRecord = await beforeBossTurn?.(baselineSnapshot);
813
+ uncertainRecord = await beforeBossTurn?.(
814
+ baselineSnapshot,
815
+ shell,
816
+ created.reconcileRepositoryEffects,
817
+ );
642
818
  } catch (error) {
643
819
  throw new HeadlessHostSetupError(error);
644
820
  }
645
821
  await assertBeforeBossTurn?.();
646
822
  if (signal?.aborted) {
647
- throw signal.reason ?? new Error('Captain turn aborted');
823
+ throw signal.reason ?? new Error("Captain turn aborted");
648
824
  }
649
825
  await host.runBossTurn(input);
650
826
  throwIfAborted(signal);
651
827
  if (
652
828
  replies.length !== 1 ||
653
- typeof replies[0] !== 'string' ||
829
+ typeof replies[0] !== "string" ||
654
830
  replies[0].trim().length === 0
655
831
  ) {
656
832
  throw new Error(
@@ -658,24 +834,29 @@ export async function driveHeadlessCaptainTurn({
658
834
  );
659
835
  }
660
836
  if (shell === undefined) {
661
- throw new Error('Captain shell host initialized without a shell');
837
+ throw new Error("Captain shell host initialized without a shell");
662
838
  }
663
- const snapshot = shell.exportSnapshot();
664
- if (snapshot === undefined) {
665
- throw new Error('Captain turn settled without an exportable session snapshot');
839
+ const settlement = shell.exportSettlement();
840
+ if (settlement === undefined) {
841
+ throw new Error(
842
+ "Captain turn settled without an exportable session settlement",
843
+ );
666
844
  }
845
+ const { snapshot, unresolvedEffects, retentionUpdates } = settlement;
667
846
  if (
668
847
  snapshot.captain?.sessionId === sessionId ||
669
848
  snapshot.issuedSessionIds?.includes(sessionId)
670
849
  ) {
671
850
  throw new Error(
672
- 'logical session id collided with an internal Captain session id',
851
+ "logical session id collided with an internal Captain session id",
673
852
  );
674
853
  }
675
854
  return {
676
855
  sessionId,
677
856
  reply: replies[0],
678
857
  snapshot,
858
+ unresolvedEffects,
859
+ retentionUpdates,
679
860
  config: cloneJson(config),
680
861
  cwd,
681
862
  uncertainRecord,
@@ -706,9 +887,51 @@ export async function driveHeadlessCaptainTurn({
706
887
  export class CaptainSessionHostCleanupError extends AggregateError {
707
888
  constructor(errors, messageText) {
708
889
  super(errors, messageText);
709
- this.name = 'CaptainSessionHostCleanupError';
710
- this.code = 'PLAYBOOK_CAPTAIN_HOST_CLEANUP_INCOMPLETE';
890
+ this.name = "CaptainSessionHostCleanupError";
891
+ this.code = "PLAYBOOK_CAPTAIN_HOST_CLEANUP_INCOMPLETE";
892
+ }
893
+ }
894
+
895
+ async function createStoreEffectLedgerService(lease) {
896
+ let mirror =
897
+ (await lease.read())?.effectLedger ?? emptyPlaybookEffectLedger();
898
+ mirror = assertPlaybookEffectLedger(mirror);
899
+ return Object.freeze({
900
+ snapshot: () => mirror,
901
+ async refresh() {
902
+ mirror = assertPlaybookEffectLedger(
903
+ (await lease.read())?.effectLedger ?? emptyPlaybookEffectLedger(),
904
+ );
905
+ return mirror;
906
+ },
907
+ async writeAhead(authority, commands) {
908
+ mirror = assertPlaybookEffectLedger(
909
+ await lease.writeEffectLedger(authority, commands),
910
+ );
911
+ return mirror;
912
+ },
913
+ });
914
+ }
915
+
916
+ function effectLedgerSnapshotFromCapabilities(hostCapabilities) {
917
+ const capabilities = Object.values(hostCapabilities);
918
+ if (capabilities.length === 0) return emptyPlaybookEffectLedger();
919
+ const mirror = assertPlaybookEffectLedger(
920
+ capabilities[0].effectLedger.snapshot(),
921
+ );
922
+ for (const capability of capabilities.slice(1)) {
923
+ if (
924
+ !isDeepStrictEqual(
925
+ assertPlaybookEffectLedger(capability.effectLedger.snapshot()),
926
+ mirror,
927
+ )
928
+ ) {
929
+ throw new Error(
930
+ "schema-3 current-host capabilities disagree on their effect ledger",
931
+ );
932
+ }
711
933
  }
934
+ return mirror;
712
935
  }
713
936
 
714
937
  function isCaptainSessionHostCleanupIncomplete(error) {
@@ -719,21 +942,137 @@ function isCaptainSessionHostCleanupIncomplete(error) {
719
942
  );
720
943
  }
721
944
 
945
+ function reconcileWholeTurnReplaySnapshot(snapshot, ledger, catalog) {
946
+ const checkpoint = assertPlaybookEffectLedger(snapshot.effectLedger);
947
+ const current = assertPlaybookEffectLedger(ledger);
948
+ const checkpointBoundaryCount = checkpoint.boundaries.length;
949
+ const currentPrefix = current.boundaries.slice(0, checkpointBoundaryCount);
950
+ if (!isDeepStrictEqual(currentPrefix, checkpoint.boundaries)) {
951
+ throw new Error(
952
+ "Captain uncertain turn repository-effect reconciliation cannot restore a changed pre-turn boundary",
953
+ );
954
+ }
955
+ if (
956
+ !isDeepStrictEqual(current.logicalOperations, checkpoint.logicalOperations)
957
+ ) {
958
+ throw new Error(
959
+ "Captain uncertain turn repository-effect reconciliation found deferred logical-operation progress and cannot replay the Boss turn",
960
+ );
961
+ }
962
+ const suffix = current.boundaries.slice(checkpointBoundaryCount);
963
+ const blocking = suffix.find(
964
+ (boundary) => boundary.physicalReceipt?.classification !== "unchanged",
965
+ );
966
+ if (blocking !== undefined) {
967
+ const classification =
968
+ blocking.physicalReceipt?.classification ?? "incomplete";
969
+ throw new Error(
970
+ `Captain uncertain turn repository-effect boundary ${JSON.stringify(blocking.boundaryId)} is ${classification}; whole-turn replay remains parked for reconciliation`,
971
+ );
972
+ }
973
+ const frames =
974
+ snapshot.mode !== "engaged.parked"
975
+ ? undefined
976
+ : snapshot.frames.map((frame) => {
977
+ if (catalog[frame.playbookId]?.artifactSchema !== 3) {
978
+ throw new Error(
979
+ `Captain uncertain turn cannot rebase frame ${JSON.stringify(frame.playbookId)} without exact artifact-schema authority`,
980
+ );
981
+ }
982
+ return {
983
+ ...frame,
984
+ runtime: { ...frame.runtime, effectLedger: current },
985
+ };
986
+ });
987
+
988
+ return assertPlaybookCaptainShellSnapshot({
989
+ ...snapshot,
990
+ effectLedger: current,
991
+ ...(frames === undefined ? {} : { frames }),
992
+ });
993
+ }
994
+
722
995
  export async function createCaptainSessionHost({
723
996
  config,
724
997
  sessionId,
725
998
  cwd,
999
+ sessionLease,
726
1000
  loadModule,
727
1001
  observers,
728
1002
  adapterImports,
729
1003
  createCaptainRuntime,
730
1004
  createCaptainSessionId,
731
1005
  createHostRuntime = createTmuxPlayRuntime,
1006
+ createEffectLedgerWriteAhead,
732
1007
  restoreSnapshot,
1008
+ reconcileUncertainTurnReplay = false,
733
1009
  signal,
734
1010
  }) {
1011
+ const hostCapabilities = await createRepositoryEffectCapabilities({
1012
+ cwd,
1013
+ catalog: config.catalog,
1014
+ sessionId,
1015
+ sessionLease,
1016
+ createWriteAhead:
1017
+ createEffectLedgerWriteAhead ?? createStoreEffectLedgerService,
1018
+ });
1019
+ const reconcileRepositoryEffects = async () => {
1020
+ await refreshRepositoryEffectCapabilities(hostCapabilities);
1021
+ await recoverIncompleteRepositoryEffects({
1022
+ catalog: config.catalog,
1023
+ capabilities: hostCapabilities,
1024
+ });
1025
+ return effectLedgerSnapshotFromCapabilities(hostCapabilities);
1026
+ };
1027
+ try {
1028
+ await reconcileRepositoryEffects();
1029
+ } catch (error) {
1030
+ if (!reconcileUncertainTurnReplay) throw error;
1031
+ throw new Error(
1032
+ `Captain uncertain turn repository-effect reconciliation failed: ${message(error)}`,
1033
+ { cause: error },
1034
+ );
1035
+ }
1036
+ const currentEffectLedger = () =>
1037
+ effectLedgerSnapshotFromCapabilities(hostCapabilities);
1038
+ let sourceSnapshot = restoreSnapshot;
1039
+ if (
1040
+ sourceSnapshot !== undefined &&
1041
+ !isDeepStrictEqual(sourceSnapshot.effectLedger, currentEffectLedger())
1042
+ ) {
1043
+ const recovered =
1044
+ typeof sessionLease.read === "function"
1045
+ ? await sessionLease.read()
1046
+ : undefined;
1047
+ if (
1048
+ recovered?.state === "settled" &&
1049
+ isDeepStrictEqual(recovered.snapshot.effectLedger, currentEffectLedger())
1050
+ ) {
1051
+ sourceSnapshot = recovered.snapshot;
1052
+ }
1053
+ }
1054
+ if (sourceSnapshot !== undefined && reconcileUncertainTurnReplay) {
1055
+ sourceSnapshot = reconcileWholeTurnReplaySnapshot(
1056
+ sourceSnapshot,
1057
+ currentEffectLedger(),
1058
+ config.catalog,
1059
+ );
1060
+ } else if (
1061
+ sourceSnapshot !== undefined &&
1062
+ !isDeepStrictEqual(sourceSnapshot.effectLedger, currentEffectLedger())
1063
+ ) {
1064
+ throw new Error(
1065
+ "Captain session effect ledger requires reconciliation before source-state restoration",
1066
+ );
1067
+ }
735
1068
  const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
736
1069
  loadModule,
1070
+ hostCapabilities,
1071
+ unresolvedEffectSettlement: {
1072
+ begin: (input) => sessionLease.beginUnresolvedEffectAbandonment(input),
1073
+ complete: (input) =>
1074
+ sessionLease.completeUnresolvedEffectAbandonment(input),
1075
+ },
737
1076
  ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
738
1077
  ...(createCaptainSessionId
739
1078
  ? { createSessionId: createCaptainSessionId }
@@ -741,12 +1080,12 @@ export async function createCaptainSessionHost({
741
1080
  });
742
1081
  let host;
743
1082
  try {
744
- const captain = captainHostBoundary(shell, restoreSnapshot);
1083
+ const captain = captainHostBoundary(shell, sourceSnapshot);
745
1084
  host = await createHostRuntime({
746
1085
  captain,
747
1086
  captainConfig: projectHostAgent(
748
1087
  config.captain,
749
- 'Captain execution config.captain',
1088
+ "Captain execution config.captain",
750
1089
  ),
751
1090
  players: config.players.map(({ id, ...agent }) => ({
752
1091
  id,
@@ -760,19 +1099,19 @@ export async function createCaptainSessionHost({
760
1099
  const snapshot = shell.exportSnapshot();
761
1100
  if (snapshot === undefined) {
762
1101
  throw new Error(
763
- 'Captain shell initialized without an exportable session snapshot',
1102
+ "Captain shell initialized without an exportable session snapshot",
764
1103
  );
765
1104
  }
766
1105
  if (
767
- restoreSnapshot !== undefined &&
768
- !isDeepStrictEqual(snapshot, restoreSnapshot)
1106
+ sourceSnapshot !== undefined &&
1107
+ !isDeepStrictEqual(snapshot, sourceSnapshot)
769
1108
  ) {
770
- throw new Error('restored Captain snapshot changed before the Boss turn');
1109
+ throw new Error("restored Captain snapshot changed before the Boss turn");
771
1110
  }
772
1111
  if (sessionId !== undefined) {
773
1112
  assertLogicalSessionIdDistinct({ sessionId, snapshot });
774
1113
  }
775
- return { shell, host, snapshot };
1114
+ return { shell, host, snapshot, reconcileRepositoryEffects };
776
1115
  } catch (error) {
777
1116
  let cleanupError;
778
1117
  try {
@@ -791,6 +1130,37 @@ export async function createCaptainSessionHost({
791
1130
  }
792
1131
  }
793
1132
 
1133
+ // PBCLI-55: both presentations install one authoritative retention map at the
1134
+ // same post-shell, pre-turn boundary. Fresh-session guarded initialization
1135
+ // publishes either the intentional empty boundary or the transferred map.
1136
+ export async function installRetainedGenerationsForLaunch({
1137
+ lease,
1138
+ shell,
1139
+ freshBoundary,
1140
+ onFreshRecord,
1141
+ onLegacyRecord,
1142
+ onInvalidRecord,
1143
+ retainedGenerations,
1144
+ reconcileRepositoryEffects,
1145
+ }) {
1146
+ let authoritative = retainedGenerations;
1147
+ if (freshBoundary !== undefined) {
1148
+ const record = await lease.initializeSettledWithPredecessor({
1149
+ ...freshBoundary,
1150
+ onLegacyRecord,
1151
+ onInvalidRecord,
1152
+ });
1153
+ authoritative = record.retainedGenerations ?? {};
1154
+ }
1155
+ await reconcileRepositoryEffects?.();
1156
+ if (freshBoundary !== undefined) {
1157
+ onFreshRecord?.(await lease.read());
1158
+ }
1159
+ await shell.installRetainedGenerations(authoritative);
1160
+ await lease.assertOwner();
1161
+ return authoritative;
1162
+ }
1163
+
794
1164
  // PBCLI-20: restoration enters through the host's one init boundary. The
795
1165
  // restored shell is fresh and receives restore instead of init, never both.
796
1166
  function captainHostBoundary(shell, restoreSnapshot) {
@@ -844,6 +1214,7 @@ export async function validateFrozenExecutionConfig(
844
1214
  structuralProjection,
845
1215
  executionProjection,
846
1216
  );
1217
+ assertFrozenFastModesSupported(config);
847
1218
  const catalogItems = Object.entries(config.catalog);
848
1219
 
849
1220
  // Preserve the complete-catalog preparation transaction: prepare every
@@ -859,7 +1230,9 @@ export async function validateFrozenExecutionConfig(
859
1230
  authoredFrom: item.from,
860
1231
  });
861
1232
  } catch (cause) {
862
- throw new Error(`stored playbook ${JSON.stringify(id)} failed to prepare: ${message(cause)}`);
1233
+ throw new Error(
1234
+ `stored playbook ${JSON.stringify(id)} failed to prepare: ${message(cause)}`,
1235
+ );
863
1236
  }
864
1237
  if (prepared !== undefined && prepared !== item.from) {
865
1238
  throw new Error(
@@ -871,12 +1244,16 @@ export async function validateFrozenExecutionConfig(
871
1244
  for (const [id, item] of catalogItems) {
872
1245
  let entry;
873
1246
  try {
874
- entry = (await loadModule(item.from))?.default;
1247
+ entry = snapshotRegistryEntry((await loadModule(item.from))?.default);
875
1248
  } catch (cause) {
876
- throw new Error(`stored playbook ${JSON.stringify(id)} failed to import: ${message(cause)}`);
1249
+ throw new Error(
1250
+ `stored playbook ${JSON.stringify(id)} failed to import: ${message(cause)}`,
1251
+ );
877
1252
  }
878
- if (!isValidRegistryEntry(entry)) {
879
- throw new Error(`stored playbook ${JSON.stringify(id)} exposes no valid registry entry`);
1253
+ if (invalidRegistryEntryReason(entry) !== undefined) {
1254
+ throw new Error(
1255
+ `stored playbook ${JSON.stringify(id)} exposes no valid registry entry`,
1256
+ );
880
1257
  }
881
1258
  if (
882
1259
  entry.id !== id ||
@@ -884,10 +1261,7 @@ export async function validateFrozenExecutionConfig(
884
1261
  entry.intent !== item.intent ||
885
1262
  entry.artifactSchema !== item.artifactSchema ||
886
1263
  !isDeepStrictEqual(entry.requiredRoleIds, item.requiredRoleIds) ||
887
- !isDeepStrictEqual(
888
- entry.concurrentRoleSets,
889
- item.concurrentRoleSets,
890
- )
1264
+ !isDeepStrictEqual(entry.concurrentRoleSets, item.concurrentRoleSets)
891
1265
  ) {
892
1266
  throw new Error(
893
1267
  `stored playbook ${JSON.stringify(id)} no longer matches its recorded manifest identity`,
@@ -897,6 +1271,44 @@ export async function validateFrozenExecutionConfig(
897
1271
  return config;
898
1272
  }
899
1273
 
1274
+ function assertFrozenFastModesSupported(config) {
1275
+ if (config.captain.fastMode !== undefined) {
1276
+ assertFastModeSupported(
1277
+ config.captain.adapter,
1278
+ "stored Captain fastMode",
1279
+ );
1280
+ }
1281
+
1282
+ const playerAdapters = new Map();
1283
+ for (const player of config.players) {
1284
+ playerAdapters.set(player.id, player.adapter);
1285
+ if (player.fastMode !== undefined) {
1286
+ assertFastModeSupported(
1287
+ player.adapter,
1288
+ `stored player ${JSON.stringify(player.id)} fastMode`,
1289
+ );
1290
+ }
1291
+ }
1292
+
1293
+ for (const [playbookId, item] of Object.entries(config.catalog)) {
1294
+ for (const [roleId, binding] of Object.entries(item.roles)) {
1295
+ if (binding.fastMode === undefined) continue;
1296
+ const adapter = playerAdapters.get(binding.playerId);
1297
+ if (adapter === undefined) {
1298
+ throw new Error(
1299
+ `stored playbook ${JSON.stringify(playbookId)} role ` +
1300
+ `${JSON.stringify(roleId)} names an unknown player`,
1301
+ );
1302
+ }
1303
+ assertFastModeSupported(
1304
+ adapter,
1305
+ `stored playbook ${JSON.stringify(playbookId)} role ` +
1306
+ `${JSON.stringify(roleId)} fastMode`,
1307
+ );
1308
+ }
1309
+ }
1310
+ }
1311
+
900
1312
  function adaptersFromExecutionConfig(config) {
901
1313
  return [
902
1314
  ...new Set([
@@ -913,7 +1325,7 @@ function assertLogicalSessionIdDistinct(record) {
913
1325
  record.snapshot.issuedSessionIds.includes(record.sessionId))
914
1326
  ) {
915
1327
  throw new Error(
916
- 'logical session id collides with an internal Captain session id',
1328
+ "logical session id collides with an internal Captain session id",
917
1329
  );
918
1330
  }
919
1331
  }
@@ -922,44 +1334,15 @@ function memoizedModuleLoader(loadModule) {
922
1334
  const modules = new Map();
923
1335
  return (specifier) => {
924
1336
  if (!modules.has(specifier)) {
925
- modules.set(specifier, Promise.resolve().then(() => loadModule(specifier)));
1337
+ modules.set(
1338
+ specifier,
1339
+ Promise.resolve().then(() => loadModule(specifier)),
1340
+ );
926
1341
  }
927
1342
  return modules.get(specifier);
928
1343
  };
929
1344
  }
930
1345
 
931
- function isValidRegistryEntry(value) {
932
- if (
933
- value === null ||
934
- typeof value !== 'object' ||
935
- Array.isArray(value) ||
936
- typeof value.id !== 'string' ||
937
- value.id.trim().length === 0 ||
938
- typeof value.command !== 'string' ||
939
- value.command.trim().length === 0 ||
940
- typeof value.intent !== 'string' ||
941
- value.artifactSchema !== 2 ||
942
- !Array.isArray(value.requiredRoleIds) ||
943
- value.requiredRoleIds.some(
944
- (role) => typeof role !== 'string' || role.trim().length === 0,
945
- ) ||
946
- new Set(value.requiredRoleIds).size !== value.requiredRoleIds.length ||
947
- !Array.isArray(value.concurrentRoleSets) ||
948
- typeof value.validateOptions !== 'function' ||
949
- typeof value.createRuntime !== 'function'
950
- ) {
951
- return false;
952
- }
953
- const roles = new Set(value.requiredRoleIds);
954
- return value.concurrentRoleSets.every(
955
- (set) =>
956
- Array.isArray(set) &&
957
- set.length >= 2 &&
958
- set.every((role) => typeof role === 'string' && roles.has(role)) &&
959
- new Set(set).size === set.length,
960
- );
961
- }
962
-
963
1346
  export function captainOptionsFromConfig(config) {
964
1347
  return {
965
1348
  playbooks: Object.fromEntries(
@@ -979,7 +1362,7 @@ export function captainOptionsFromConfig(config) {
979
1362
  config.players.map(({ id, ...agent }) => [id, cloneJson(agent)]),
980
1363
  ),
981
1364
  },
982
- ...(typeof config.captain.adapter === 'string' &&
1365
+ ...(typeof config.captain.adapter === "string" &&
983
1366
  config.captain.adapter.length > 0
984
1367
  ? { captainAdapter: config.captain.adapter }
985
1368
  : {}),
@@ -1013,65 +1396,65 @@ export function parseRunArgs(argv) {
1013
1396
  const positionals = [];
1014
1397
  for (let index = 0; index < argv.length; index += 1) {
1015
1398
  const arg = argv[index];
1016
- if (arg === '--') {
1399
+ if (arg === "--") {
1017
1400
  parsed.terminated = true;
1018
1401
  positionals.push(...argv.slice(index + 1));
1019
1402
  break;
1020
1403
  }
1021
- if (arg === '--help' || arg === '-h') parsed.help = true;
1022
- else if (arg === '--json') parsed.json = true;
1023
- else if (arg === '--verbose') parsed.verbose = true;
1024
- else if (arg === '--no-provision') parsed.noProvision = true;
1025
- else if (arg === '--retry-uncertain') {
1404
+ if (arg === "--help" || arg === "-h") parsed.help = true;
1405
+ else if (arg === "--json") parsed.json = true;
1406
+ else if (arg === "--verbose") parsed.verbose = true;
1407
+ else if (arg === "--no-provision") parsed.noProvision = true;
1408
+ else if (arg === "--retry-uncertain") {
1026
1409
  if (parsed.retryUncertain) {
1027
- throw new Error('--retry-uncertain may be specified only once');
1410
+ throw new Error("--retry-uncertain may be specified only once");
1028
1411
  }
1029
1412
  parsed.retryUncertain = true;
1030
- } else if (arg === '--discard-uncertain') {
1413
+ } else if (arg === "--discard-uncertain") {
1031
1414
  if (parsed.discardUncertain) {
1032
- throw new Error('--discard-uncertain may be specified only once');
1415
+ throw new Error("--discard-uncertain may be specified only once");
1033
1416
  }
1034
1417
  parsed.discardUncertain = true;
1035
- }
1036
- else if (arg === '--continue') {
1037
- if (parsed.continue) throw new Error('--continue may be specified only once');
1418
+ } else if (arg === "--continue") {
1419
+ if (parsed.continue)
1420
+ throw new Error("--continue may be specified only once");
1038
1421
  parsed.continue = true;
1039
- } else if (arg === '--session') {
1422
+ } else if (arg === "--session") {
1040
1423
  const value = argv[index + 1];
1041
- if (value === undefined || value === '') {
1042
- throw new Error('--session needs a UUID value');
1424
+ if (value === undefined || value === "") {
1425
+ throw new Error("--session needs a UUID value");
1043
1426
  }
1044
1427
  if (parsed.sessionId !== undefined) {
1045
- throw new Error('--session may be specified only once');
1428
+ throw new Error("--session may be specified only once");
1046
1429
  }
1047
1430
  parsed.sessionId = value;
1048
1431
  index += 1;
1049
- } else if (arg.startsWith('--session=')) {
1050
- const value = arg.slice('--session='.length);
1051
- if (value === '') throw new Error('--session needs a UUID value');
1432
+ } else if (arg.startsWith("--session=")) {
1433
+ const value = arg.slice("--session=".length);
1434
+ if (value === "") throw new Error("--session needs a UUID value");
1052
1435
  if (parsed.sessionId !== undefined) {
1053
- throw new Error('--session may be specified only once');
1436
+ throw new Error("--session may be specified only once");
1054
1437
  }
1055
1438
  parsed.sessionId = value;
1056
- } else if (arg === '--with') {
1439
+ } else if (arg === "--with") {
1057
1440
  const value = argv[index + 1];
1058
- if (value === undefined || value === '') {
1059
- throw new Error('--with needs a value');
1441
+ if (value === undefined || value === "") {
1442
+ throw new Error("--with needs a value");
1060
1443
  }
1061
1444
  parsed.withPaths.push(value);
1062
1445
  index += 1;
1063
- } else if (arg.startsWith('--with=')) {
1064
- const value = arg.slice('--with='.length);
1065
- if (value === '') throw new Error('--with needs a value');
1446
+ } else if (arg.startsWith("--with=")) {
1447
+ const value = arg.slice("--with=".length);
1448
+ if (value === "") throw new Error("--with needs a value");
1066
1449
  parsed.withPaths.push(value);
1067
1450
  } else if (
1068
1451
  RETIRED_FLAGS.has(arg) ||
1069
1452
  [...RETIRED_FLAGS].some((flag) => arg.startsWith(`${flag}=`))
1070
1453
  ) {
1071
1454
  throw new Error(
1072
- `${arg.split('=')[0]} was removed; configure the shared Captain session in playbook.config.yaml or a --with overlay`,
1455
+ `${arg.split("=")[0]} was removed; configure the shared Captain session in playbook.config.yaml or a --with overlay`,
1073
1456
  );
1074
- } else if (arg.startsWith('-')) {
1457
+ } else if (arg.startsWith("-")) {
1075
1458
  throw new Error(`unknown option ${arg}`);
1076
1459
  } else {
1077
1460
  positionals.push(arg);
@@ -1079,15 +1462,15 @@ export function parseRunArgs(argv) {
1079
1462
  }
1080
1463
  if (positionals.length > 1) {
1081
1464
  throw new Error(
1082
- 'expected at most one [input] argument; quote multi-word input as one shell argument',
1465
+ "expected at most one [input] argument; quote multi-word input as one shell argument",
1083
1466
  );
1084
1467
  }
1085
1468
  if (parsed.continue && parsed.sessionId !== undefined) {
1086
- throw new Error('--continue and --session are mutually exclusive');
1469
+ throw new Error("--continue and --session are mutually exclusive");
1087
1470
  }
1088
1471
  if (parsed.retryUncertain && parsed.discardUncertain) {
1089
1472
  throw new Error(
1090
- '--retry-uncertain and --discard-uncertain are mutually exclusive',
1473
+ "--retry-uncertain and --discard-uncertain are mutually exclusive",
1091
1474
  );
1092
1475
  }
1093
1476
  if (
@@ -1095,7 +1478,7 @@ export function parseRunArgs(argv) {
1095
1478
  parsed.sessionId === undefined
1096
1479
  ) {
1097
1480
  throw new Error(
1098
- '--retry-uncertain and --discard-uncertain require --session <id>',
1481
+ "--retry-uncertain and --discard-uncertain require --session <id>",
1099
1482
  );
1100
1483
  }
1101
1484
  if (
@@ -1103,7 +1486,7 @@ export function parseRunArgs(argv) {
1103
1486
  (parsed.continue || positionals.length > 0)
1104
1487
  ) {
1105
1488
  throw new Error(
1106
- 'uncertain-turn recovery accepts only an explicit --session and no input',
1489
+ "uncertain-turn recovery accepts only an explicit --session and no input",
1107
1490
  );
1108
1491
  }
1109
1492
  if (
@@ -1111,20 +1494,20 @@ export function parseRunArgs(argv) {
1111
1494
  (parsed.json || parsed.verbose || parsed.noProvision)
1112
1495
  ) {
1113
1496
  throw new Error(
1114
- '--discard-uncertain does not accept --json, --verbose, or --no-provision',
1497
+ "--discard-uncertain does not accept --json, --verbose, or --no-provision",
1115
1498
  );
1116
1499
  }
1117
1500
  if (
1118
1501
  (parsed.retryUncertain || parsed.discardUncertain) &&
1119
1502
  parsed.withPaths.length > 0
1120
1503
  ) {
1121
- throw new Error('--with is unavailable during uncertain-turn recovery');
1504
+ throw new Error("--with is unavailable during uncertain-turn recovery");
1122
1505
  }
1123
1506
  if (
1124
1507
  parsed.sessionId !== undefined &&
1125
1508
  !SESSION_ID_PATTERN.test(parsed.sessionId)
1126
1509
  ) {
1127
- throw new Error('--session needs a canonical UUID value');
1510
+ throw new Error("--session needs a canonical UUID value");
1128
1511
  }
1129
1512
  parsed.input = positionals[0];
1130
1513
  return parsed;
@@ -1147,18 +1530,15 @@ async function reportReadinessFailure({
1147
1530
  const [first, ...rest] = lines;
1148
1531
  await writeStream(
1149
1532
  stderr,
1150
- [
1151
- ...(first ? [`playbook run: ${first}`] : []),
1152
- ...rest,
1153
- ]
1533
+ [...(first ? [`playbook run: ${first}`] : []), ...rest]
1154
1534
  .map((line) => `${line}\n`)
1155
- .join(''),
1535
+ .join(""),
1156
1536
  );
1157
1537
  }
1158
1538
  if (failingAdapters.length > 0) {
1159
1539
  await writeStream(
1160
1540
  stderr,
1161
- `playbook run: adapters not ready: ${failingAdapters.join(', ')}\n`,
1541
+ `playbook run: adapters not ready: ${failingAdapters.join(", ")}\n`,
1162
1542
  );
1163
1543
  }
1164
1544
  }
@@ -1182,7 +1562,7 @@ async function resolveBossInput(input, options, stderr) {
1182
1562
  if (resolved.trim().length === 0) {
1183
1563
  await writeStream(
1184
1564
  stderr,
1185
- 'playbook run: empty input; pass one argument or pipe a Boss message on stdin\n',
1565
+ "playbook run: empty input; pass one argument or pipe a Boss message on stdin\n",
1186
1566
  );
1187
1567
  return { ok: false };
1188
1568
  }
@@ -1191,22 +1571,22 @@ async function resolveBossInput(input, options, stderr) {
1191
1571
 
1192
1572
  async function awaitWithAbort(value, signal) {
1193
1573
  if (signal === undefined) return value;
1194
- if (signal.aborted) throw signal.reason ?? new Error('operation aborted');
1574
+ if (signal.aborted) throw signal.reason ?? new Error("operation aborted");
1195
1575
  let onAbort;
1196
1576
  const aborted = new Promise((_, reject) => {
1197
- onAbort = () => reject(signal.reason ?? new Error('operation aborted'));
1198
- signal.addEventListener('abort', onAbort, { once: true });
1577
+ onAbort = () => reject(signal.reason ?? new Error("operation aborted"));
1578
+ signal.addEventListener("abort", onAbort, { once: true });
1199
1579
  });
1200
1580
  try {
1201
1581
  return await Promise.race([value, aborted]);
1202
1582
  } finally {
1203
- signal.removeEventListener('abort', onAbort);
1583
+ signal.removeEventListener("abort", onAbort);
1204
1584
  }
1205
1585
  }
1206
1586
 
1207
1587
  function throwIfAborted(signal) {
1208
1588
  if (signal?.aborted) {
1209
- throw signal.reason ?? new Error('operation aborted');
1589
+ throw signal.reason ?? new Error("operation aborted");
1210
1590
  }
1211
1591
  }
1212
1592
 
@@ -1217,14 +1597,14 @@ function registryPreparer(args, options, stderr) {
1217
1597
  enabled: !args.noProvision,
1218
1598
  stderr,
1219
1599
  hostRoots: options.hostRoots,
1220
- commandName: 'playbook run',
1600
+ commandName: "playbook run",
1221
1601
  })
1222
1602
  );
1223
1603
  }
1224
1604
 
1225
1605
  function createAttemptId(options) {
1226
1606
  const attemptId = (options.createAttemptId ?? randomUUID)();
1227
- if (typeof attemptId !== 'string' || !UUID_PATTERN.test(attemptId)) {
1607
+ if (typeof attemptId !== "string" || !UUID_PATTERN.test(attemptId)) {
1228
1608
  throw new Error(
1229
1609
  `uncertain turn attempt id generator returned a non-UUID value: ${JSON.stringify(attemptId)}`,
1230
1610
  );
@@ -1234,35 +1614,106 @@ function createAttemptId(options) {
1234
1614
 
1235
1615
  async function releaseLease(lease) {
1236
1616
  if (lease === undefined) return undefined;
1617
+ const replayChannel = HEADLESS_REPLAY_CHANNELS.get(lease);
1237
1618
  try {
1238
- await lease.release();
1619
+ const status = await lease.release();
1620
+ await reportHeadlessReplay(replayChannel, status);
1621
+ HEADLESS_REPLAY_CHANNELS.delete(lease);
1239
1622
  return undefined;
1240
1623
  } catch (error) {
1624
+ await reportHeadlessReplay(replayChannel);
1241
1625
  return error;
1242
1626
  }
1243
1627
  }
1244
1628
 
1629
+ function createHeadlessReplayChannel({ lease, sessionId, stderr }) {
1630
+ if (
1631
+ typeof lease?.append !== "function" ||
1632
+ typeof lease?.streamStatus !== "function"
1633
+ ) {
1634
+ return undefined;
1635
+ }
1636
+ let warningPending = false;
1637
+ let warningAttempted = false;
1638
+ const replay = createReplayRecordObserver({
1639
+ lease,
1640
+ onIncomplete() {
1641
+ warningPending = true;
1642
+ },
1643
+ });
1644
+ const channel = Object.freeze({
1645
+ ...replay,
1646
+ async flushWarning() {
1647
+ if (!warningPending || warningAttempted) return;
1648
+ warningAttempted = true;
1649
+ try {
1650
+ await writeStream(
1651
+ stderr,
1652
+ `playbook run: ${replayIncompleteMessage(sessionId)}\n`,
1653
+ );
1654
+ } catch {
1655
+ // The bounded warning is best-effort and never changes run outcome.
1656
+ }
1657
+ },
1658
+ });
1659
+ HEADLESS_REPLAY_CHANNELS.set(lease, channel);
1660
+ return channel;
1661
+ }
1662
+
1663
+ async function reportHeadlessReplay(channel, status) {
1664
+ if (channel === undefined) return;
1665
+ await channel.reportIfIncomplete(status);
1666
+ await channel.flushWarning();
1667
+ }
1668
+
1245
1669
  async function reportUncertainSession(stderr, sessionId) {
1246
1670
  await writeStream(
1247
1671
  stderr,
1248
1672
  [
1249
1673
  `playbook run: Captain session ${JSON.stringify(sessionId)} has an uncertain turn and will not be replayed automatically`,
1250
- 'Retry may duplicate external effects from the interrupted attempt; discard abandons that attempted turn.',
1674
+ "Retry may duplicate external effects from the interrupted attempt; discard abandons that attempted turn.",
1251
1675
  `playbook run --session ${sessionId} --retry-uncertain`,
1252
1676
  `playbook run --session ${sessionId} --discard-uncertain`,
1253
- '',
1254
- ].join('\n'),
1677
+ "",
1678
+ ].join("\n"),
1679
+ );
1680
+ }
1681
+
1682
+ function legacyCaptainSessionReason(schemaVersion) {
1683
+ if (schemaVersion === 2) {
1684
+ return "schema 2 has incompatible player identity";
1685
+ }
1686
+ if (schemaVersion === 3 || schemaVersion === 4) {
1687
+ return `schema ${JSON.stringify(schemaVersion)} predates the artifact-schema-3 effect-authority cutover and is not resumable`;
1688
+ }
1689
+ if (schemaVersion === 5) {
1690
+ return "schema 5 predates the canonical schema-6 unresolved-effect settlement boundary for the artifact-schema-3 effect-authority cutover and is not resumable";
1691
+ }
1692
+ return `schema ${JSON.stringify(schemaVersion)} is unsupported`;
1693
+ }
1694
+
1695
+ export async function reportSkippedCaptainSession(
1696
+ stderr,
1697
+ commandName,
1698
+ kind,
1699
+ { sessionId, path, schemaVersion, reason },
1700
+ ) {
1701
+ const explanation =
1702
+ kind === "legacy" ? legacyCaptainSessionReason(schemaVersion) : reason;
1703
+ await writeStream(
1704
+ stderr,
1705
+ `${commandName}: skipping ${kind} Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} because ${explanation}; move it outside the sessions directory or remove it to silence this warning\n`,
1255
1706
  );
1256
1707
  }
1257
1708
 
1258
1709
  function replayInvocation(argv, args, input) {
1259
1710
  if (args.retryUncertain || args.discardUncertain) {
1260
- return ['run', ...argv];
1711
+ return ["run", ...argv];
1261
1712
  }
1262
- if (args.input !== undefined) return ['run', ...argv];
1713
+ if (args.input !== undefined) return ["run", ...argv];
1263
1714
  return args.terminated
1264
- ? ['run', ...argv, input]
1265
- : ['run', ...argv, '--', input];
1715
+ ? ["run", ...argv, input]
1716
+ : ["run", ...argv, "--", input];
1266
1717
  }
1267
1718
 
1268
1719
  function cloneJson(value) {
@@ -1274,67 +1725,70 @@ async function readAllStdin() {
1274
1725
  for await (const chunk of process.stdin) {
1275
1726
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1276
1727
  }
1277
- return Buffer.concat(chunks).toString('utf8');
1728
+ return Buffer.concat(chunks).toString("utf8");
1278
1729
  }
1279
1730
 
1280
1731
  async function writeStream(stream, text) {
1281
1732
  const ready = stream.write(text);
1282
- if (ready !== false || typeof stream.once !== 'function') return;
1733
+ if (ready !== false || typeof stream.once !== "function") return;
1283
1734
  await new Promise((resolvePromise, rejectPromise) => {
1284
1735
  const onDrain = () => {
1285
- stream.off?.('error', onError);
1736
+ stream.off?.("error", onError);
1286
1737
  resolvePromise();
1287
1738
  };
1288
1739
  const onError = (error) => {
1289
- stream.off?.('drain', onDrain);
1740
+ stream.off?.("drain", onDrain);
1290
1741
  rejectPromise(error);
1291
1742
  };
1292
- stream.once('drain', onDrain);
1293
- stream.once('error', onError);
1743
+ stream.once("drain", onDrain);
1744
+ stream.once("error", onError);
1294
1745
  });
1295
1746
  }
1296
1747
 
1297
1748
  function runHelpText(userConfigPath) {
1298
1749
  return [
1299
- 'Usage:',
1300
- ' playbook run [--with <path>]... [--no-provision] [--json]',
1301
- ' [--verbose] [--] [input]',
1302
- ' playbook run (--continue | --session <id>) [--with <path>]...',
1303
- ' [--no-provision] [--json] [--verbose] [--] [reply]',
1304
- ' playbook run --session <id> --retry-uncertain [--no-provision]',
1305
- ' playbook run --session <id> --discard-uncertain',
1306
- '',
1307
- ' [input] one exact Boss message; read verbatim from stdin when omitted',
1308
- ' -- end options so a flag-shaped input remains Boss text',
1309
- '',
1750
+ "Usage:",
1751
+ " playbook run [--with <path>]... [--no-provision] [--json]",
1752
+ " [--verbose] [--] [input]",
1753
+ " playbook run (--continue | --session <id>) [--with <path>]...",
1754
+ " [--no-provision] [--json] [--verbose] [--] [reply]",
1755
+ " playbook run --session <id> --retry-uncertain [--no-provision]",
1756
+ " playbook run --session <id> --discard-uncertain",
1757
+ "",
1758
+ " [input] one exact Boss message; read verbatim from stdin when omitted",
1759
+ " -- end options so a flag-shaped input remains Boss text",
1760
+ "",
1310
1761
  `Default config: ${userConfigPath}`,
1311
- '',
1312
- 'A new run uses the same configured Captain, enabled playbooks, players,',
1313
- 'options, overlays, provisioning, and readiness gate as interactive',
1314
- '`playbook`. Enable an external registry in that config, then invoke its',
1315
- 'effective /command through Captain. The former positional registry,',
1316
- 'resume, and run-only binding surfaces have been removed.',
1317
- 'Stable agents live under top-level players; every playbook-local role',
1318
- 'binds explicitly under playbooks.<id>.roles. Equal player ids share one',
1319
- 'provider conversation; distinct ids remain isolated.',
1320
- 'Legacy playbooks.<id>.players is rejected and is not auto-migrated,',
1321
- 'because choosing new ids decides sharing versus isolation.',
1322
- 'An ordinary continued run restores the stored structure and working',
1323
- 'directory, then reads current config and overlays for model and effort.',
1324
- 'Uncertain retry instead uses its exact recorded input and settings.',
1325
- '',
1326
- 'Options:',
1327
- ' --with <path> overlay a generic config fragment (repeatable)',
1328
- ' --no-provision do not provision thin filesystem registry engines',
1329
- ' --continue reply to the latest durable Captain session',
1330
- ' --session <id> reply to one durable Captain session UUID',
1331
- ' --retry-uncertain retry that session\'s exact recorded uncertain input',
1332
- ' --discard-uncertain discard that session\'s uncertain attempt',
1762
+ "",
1763
+ "A new run uses the same configured Captain, enabled playbooks, players,",
1764
+ "options, overlays, provisioning, and readiness gate as interactive",
1765
+ "`playbook`. Enable an external registry in that config, then invoke its",
1766
+ "effective /command through Captain. The former positional registry,",
1767
+ "resume, and run-only binding surfaces have been removed.",
1768
+ "Stable agents live under top-level players; every playbook-local role",
1769
+ "binds explicitly under playbooks.<id>.roles. Equal player ids share one",
1770
+ "provider conversation; distinct ids remain isolated.",
1771
+ "Legacy playbooks.<id>.players is rejected and is not auto-migrated,",
1772
+ "because choosing new ids decides sharing versus isolation.",
1773
+ "Bare continuation prefers the newest session stored for the invoking",
1774
+ "working directory and reports when it uses the global newest fallback.",
1775
+ "An ordinary continued run restores that stored structure and working",
1776
+ "directory, then reads current config and overlays for model, effort,",
1777
+ "and fast mode.",
1778
+ "Uncertain retry instead uses its exact recorded input and settings.",
1779
+ "",
1780
+ "Options:",
1781
+ " --with <path> overlay a generic config fragment (repeatable)",
1782
+ " --no-provision do not provision thin filesystem registry engines",
1783
+ " --continue prefer this working directory, else global newest",
1784
+ " --session <id> reply to one durable Captain session UUID",
1785
+ " --retry-uncertain retry that session's exact recorded uncertain input",
1786
+ " --discard-uncertain discard that session's uncertain attempt",
1333
1787
  ' --json print exactly {"sessionId", "reply"}',
1334
- ' --verbose print Captain telemetry topics to stderr',
1335
- ' -h, --help print this help without reading input or config',
1336
- '',
1337
- ].join('\n');
1788
+ " --verbose print Captain telemetry topics to stderr",
1789
+ " -h, --help print this help without reading input or config",
1790
+ "",
1791
+ ].join("\n");
1338
1792
  }
1339
1793
 
1340
1794
  function message(error) {