@sublang/playbook 7.0.0 → 9.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 (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -8,10 +8,9 @@
8
8
 
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { homedir } from 'node:os';
11
- import { isAbsolute, resolve } from 'node:path';
11
+ import { resolve } from 'node:path';
12
12
  import { isDeepStrictEqual } from 'node:util';
13
13
  import { createTmuxPlayRuntime } from '@sublang/cligent/tmux-play';
14
- import { snapshotJsonValue } from '../../../../src/xstate-runtime.js';
15
14
  import { createPlaybookCaptainShell } from '../playbook-captain.js';
16
15
  import {
17
16
  adapterSdkFailureLines,
@@ -22,14 +21,17 @@ import {
22
21
  import {
23
22
  checkReadiness,
24
23
  loadLaunchPlan,
25
- normalizeHostConfig,
26
- PLAYBOOK_CAPTAIN_MODULE,
24
+ projectHostAgent,
27
25
  resolveUserConfigPath,
28
26
  } from './launch-config.js';
29
27
  import { prepareConfiguredRegistries } from './provision.js';
30
28
  import {
29
+ assertCaptainSessionExecutionCompatible,
30
+ captainSessionSelectedMembers,
31
31
  createCaptainSessionStore,
32
+ projectCaptainSessionStructure,
32
33
  SESSION_ID_PATTERN,
34
+ validateCaptainSessionExecutionProjection,
33
35
  validateCaptainSessionRecord,
34
36
  } from './session-store.js';
35
37
 
@@ -121,7 +123,16 @@ export async function runPlaybookRun(options = {}) {
121
123
  try {
122
124
  if (args.sessionId === undefined) {
123
125
  const selected = validateCaptainSessionRecord(
124
- await awaitWithAbort(store.latest(), options.signal),
126
+ await awaitWithAbort(
127
+ store.latest({
128
+ onLegacyRecord: ({ sessionId: legacyId, path }) =>
129
+ writeStream(
130
+ 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`,
132
+ ),
133
+ }),
134
+ options.signal,
135
+ ),
125
136
  );
126
137
  sessionId = selected.sessionId;
127
138
  } else {
@@ -230,7 +241,9 @@ export async function runPlaybookRun(options = {}) {
230
241
 
231
242
  cwd = priorRecord.cwd;
232
243
  restoreSnapshot = priorRecord.snapshot;
233
- } else {
244
+ }
245
+
246
+ if (!continuing || !args.retryUncertain) {
234
247
  const userConfigPath =
235
248
  options.userConfigPath ?? resolveUserConfigPath(env, home);
236
249
  let plan;
@@ -243,24 +256,47 @@ export async function runPlaybookRun(options = {}) {
243
256
  loadModule,
244
257
  prepareRegistryModule,
245
258
  onNotice: (line) => configNotices.push(line),
259
+ ...(continuing
260
+ ? {
261
+ selectedMembers: captainSessionSelectedMembers(
262
+ priorRecord.structuralProjection,
263
+ ),
264
+ }
265
+ : {}),
246
266
  });
247
267
  throwIfAborted(options.signal);
248
268
  } catch (error) {
249
269
  for (const line of configNotices) await writeStream(stderr, line);
270
+ const releaseError = await releaseLease(lease);
250
271
  await writeStream(stderr, `playbook run: ${message(error)}\n`);
272
+ if (releaseError !== undefined) {
273
+ await writeStream(
274
+ stderr,
275
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
276
+ );
277
+ return { code: EXIT.turn };
278
+ }
251
279
  return { code: EXIT.argument };
252
280
  }
253
281
  for (const line of configNotices) await writeStream(stderr, line);
254
282
 
255
283
  try {
256
- sessionId = (options.createLogicalSessionId ?? randomUUID)();
257
- if (typeof sessionId !== 'string' || !UUID_PATTERN.test(sessionId)) {
258
- throw new Error(
259
- `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
284
+ const current = executionConfigFromPlan(plan);
285
+ if (continuing) {
286
+ config = assertCaptainSessionExecutionCompatible(
287
+ priorRecord.structuralProjection,
288
+ current,
260
289
  );
290
+ } else {
291
+ sessionId = (options.createLogicalSessionId ?? randomUUID)();
292
+ if (typeof sessionId !== 'string' || !UUID_PATTERN.test(sessionId)) {
293
+ throw new Error(
294
+ `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
295
+ );
296
+ }
297
+ config = current;
298
+ cwd = resolve(options.cwd ?? process.cwd());
261
299
  }
262
- config = executionConfigFromPlan(plan);
263
- cwd = resolve(options.cwd ?? process.cwd());
264
300
  } catch (error) {
265
301
  const releaseError = await releaseLease(lease);
266
302
  await writeStream(stderr, `playbook run: ${message(error)}\n`);
@@ -273,15 +309,14 @@ export async function runPlaybookRun(options = {}) {
273
309
  }
274
310
  return { code: EXIT.argument };
275
311
  }
276
- }
277
-
278
- if (continuing) {
312
+ } else {
279
313
  try {
280
314
  throwIfAborted(options.signal);
281
- config = await validateFrozenExecutionConfig(priorRecord.config, {
282
- loadModule,
283
- prepareRegistryModule,
284
- });
315
+ config = await validateFrozenExecutionConfig(
316
+ priorRecord.structuralProjection,
317
+ priorRecord.uncertain.attemptedExecutionProjection,
318
+ { loadModule, prepareRegistryModule },
319
+ );
285
320
  throwIfAborted(options.signal);
286
321
  } catch (error) {
287
322
  const releaseError = await releaseLease(lease);
@@ -424,11 +459,13 @@ export async function runPlaybookRun(options = {}) {
424
459
  : lease.beginTurn({
425
460
  input,
426
461
  attemptId,
462
+ attemptedExecutionProjection: config,
427
463
  ...(priorRecord === undefined
428
464
  ? {
429
465
  fresh: {
430
466
  cwd,
431
- config,
467
+ structuralProjection:
468
+ projectCaptainSessionStructure(config),
432
469
  snapshot: baselineSnapshot,
433
470
  },
434
471
  }
@@ -438,8 +475,17 @@ export async function runPlaybookRun(options = {}) {
438
475
  assertBeforeBossTurn: () => lease.assertOwner(),
439
476
  });
440
477
  } catch (error) {
441
- const releaseError = await releaseLease(lease);
478
+ const cleanupIncomplete = isCaptainSessionHostCleanupIncomplete(error);
479
+ const releaseError = cleanupIncomplete
480
+ ? undefined
481
+ : await releaseLease(lease);
442
482
  await writeStream(stderr, `playbook run: ${message(error)}\n`);
483
+ if (cleanupIncomplete) {
484
+ await writeStream(
485
+ stderr,
486
+ 'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
487
+ );
488
+ }
443
489
  if (releaseError !== undefined) {
444
490
  await writeStream(
445
491
  stderr,
@@ -462,15 +508,31 @@ export async function runPlaybookRun(options = {}) {
462
508
  snapshot: settled.snapshot,
463
509
  });
464
510
  } catch (error) {
511
+ let cleanupError;
465
512
  try {
466
513
  await settled.dispose();
467
- } catch {
468
- // Preserve the failed durable hand-off as the primary diagnostic.
514
+ } catch (cause) {
515
+ cleanupError = cause;
469
516
  }
470
517
  await writeStream(
471
518
  stderr,
472
519
  `playbook run: cannot persist Captain session: ${message(error)}\n`,
473
520
  );
521
+ if (cleanupError !== undefined) {
522
+ const cleanupFailure = new CaptainSessionHostCleanupError(
523
+ [error, cleanupError],
524
+ `Captain session settlement failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
525
+ );
526
+ await writeStream(
527
+ 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',
533
+ );
534
+ return { code: EXIT.turn };
535
+ }
474
536
  const releaseError = await releaseLease(lease);
475
537
  if (releaseError !== undefined) {
476
538
  await writeStream(
@@ -550,21 +612,11 @@ export async function driveHeadlessCaptainTurn({
550
612
  let uncertainRecord;
551
613
  try {
552
614
  try {
553
- shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
554
- loadModule,
555
- ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
556
- ...(createCaptainSessionId
557
- ? { createSessionId: createCaptainSessionId }
558
- : {}),
559
- });
560
- const captain = captainHostBoundary(shell, restoreSnapshot);
561
- host = await createHostRuntime({
562
- captain,
563
- captainConfig: cloneJson(config.captain),
564
- players: cloneJson(config.players),
615
+ const created = await createCaptainSessionHost({
616
+ config,
617
+ sessionId,
565
618
  cwd,
566
- ...(signal ? { signal } : {}),
567
- ...(adapterImports ? { adapterImports } : {}),
619
+ loadModule,
568
620
  observers: [
569
621
  {
570
622
  async onRecord(record) {
@@ -578,26 +630,14 @@ export async function driveHeadlessCaptainTurn({
578
630
  },
579
631
  },
580
632
  ],
633
+ ...(signal ? { signal } : {}),
634
+ ...(adapterImports ? { adapterImports } : {}),
635
+ ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
636
+ ...(createCaptainSessionId ? { createCaptainSessionId } : {}),
637
+ createHostRuntime,
638
+ ...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
581
639
  });
582
- if (shell === undefined) {
583
- throw new Error('Captain shell host initialized without a shell');
584
- }
585
- baselineSnapshot = shell.exportSnapshot();
586
- if (baselineSnapshot === undefined) {
587
- throw new Error(
588
- 'Captain shell initialized without an exportable session snapshot',
589
- );
590
- }
591
- if (
592
- restoreSnapshot !== undefined &&
593
- !isDeepStrictEqual(baselineSnapshot, restoreSnapshot)
594
- ) {
595
- throw new Error('restored Captain snapshot changed before the Boss turn');
596
- }
597
- assertLogicalSessionIdDistinct({
598
- sessionId,
599
- snapshot: baselineSnapshot,
600
- });
640
+ ({ shell, host, snapshot: baselineSnapshot } = created);
601
641
  uncertainRecord = await beforeBossTurn?.(baselineSnapshot);
602
642
  } catch (error) {
603
643
  throw new HeadlessHostSetupError(error);
@@ -645,15 +685,113 @@ export async function driveHeadlessCaptainTurn({
645
685
  if (host !== undefined) {
646
686
  try {
647
687
  await host.dispose();
648
- } catch {
649
- // Preserve the turn/capture failure as the primary diagnostic.
688
+ } catch (cleanupError) {
689
+ const cleanupFailure = new CaptainSessionHostCleanupError(
690
+ [error, cleanupError],
691
+ `Captain session turn failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
692
+ );
693
+ throw error instanceof HeadlessHostSetupError
694
+ ? new HeadlessHostSetupError(cleanupFailure)
695
+ : cleanupFailure;
650
696
  }
651
697
  }
652
698
  throw error;
653
699
  }
654
700
  }
655
701
 
656
- // Task 7's restore path must enter through the host's one init boundary: a
702
+ // PBCLI-20/49: both presentations construct the same shell and cligent core.
703
+ // A caller supplies only observers and lifecycle ownership; the configured
704
+ // Captain, referenced-player roster, working directory, and restore boundary
705
+ // remain one implementation.
706
+ export class CaptainSessionHostCleanupError extends AggregateError {
707
+ constructor(errors, messageText) {
708
+ super(errors, messageText);
709
+ this.name = 'CaptainSessionHostCleanupError';
710
+ this.code = 'PLAYBOOK_CAPTAIN_HOST_CLEANUP_INCOMPLETE';
711
+ }
712
+ }
713
+
714
+ function isCaptainSessionHostCleanupIncomplete(error) {
715
+ if (error instanceof CaptainSessionHostCleanupError) return true;
716
+ return (
717
+ error instanceof HeadlessHostSetupError &&
718
+ isCaptainSessionHostCleanupIncomplete(error.cause)
719
+ );
720
+ }
721
+
722
+ export async function createCaptainSessionHost({
723
+ config,
724
+ sessionId,
725
+ cwd,
726
+ loadModule,
727
+ observers,
728
+ adapterImports,
729
+ createCaptainRuntime,
730
+ createCaptainSessionId,
731
+ createHostRuntime = createTmuxPlayRuntime,
732
+ restoreSnapshot,
733
+ signal,
734
+ }) {
735
+ const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
736
+ loadModule,
737
+ ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
738
+ ...(createCaptainSessionId
739
+ ? { createSessionId: createCaptainSessionId }
740
+ : {}),
741
+ });
742
+ let host;
743
+ try {
744
+ const captain = captainHostBoundary(shell, restoreSnapshot);
745
+ host = await createHostRuntime({
746
+ captain,
747
+ captainConfig: projectHostAgent(
748
+ config.captain,
749
+ 'Captain execution config.captain',
750
+ ),
751
+ players: config.players.map(({ id, ...agent }) => ({
752
+ id,
753
+ ...projectHostAgent(agent, `Captain execution config.players.${id}`),
754
+ })),
755
+ cwd,
756
+ observers,
757
+ ...(signal ? { signal } : {}),
758
+ ...(adapterImports ? { adapterImports } : {}),
759
+ });
760
+ const snapshot = shell.exportSnapshot();
761
+ if (snapshot === undefined) {
762
+ throw new Error(
763
+ 'Captain shell initialized without an exportable session snapshot',
764
+ );
765
+ }
766
+ if (
767
+ restoreSnapshot !== undefined &&
768
+ !isDeepStrictEqual(snapshot, restoreSnapshot)
769
+ ) {
770
+ throw new Error('restored Captain snapshot changed before the Boss turn');
771
+ }
772
+ if (sessionId !== undefined) {
773
+ assertLogicalSessionIdDistinct({ sessionId, snapshot });
774
+ }
775
+ return { shell, host, snapshot };
776
+ } catch (error) {
777
+ let cleanupError;
778
+ try {
779
+ if (host !== undefined) await host.dispose();
780
+ else await shell.dispose?.();
781
+ } catch (cause) {
782
+ cleanupError = cause;
783
+ }
784
+ if (cleanupError !== undefined) {
785
+ throw new CaptainSessionHostCleanupError(
786
+ [error, cleanupError],
787
+ `Captain session host construction failed (${message(error)}) and cleanup also failed: ${message(cleanupError)}`,
788
+ );
789
+ }
790
+ throw error;
791
+ }
792
+ }
793
+
794
+ // PBCLI-20: restoration enters through the host's one init boundary. The
657
795
  // restored shell is fresh and receives restore instead of init, never both.
658
796
  function captainHostBoundary(shell, restoreSnapshot) {
659
797
  return {
@@ -670,195 +808,43 @@ function captainHostBoundary(shell, restoreSnapshot) {
670
808
  // Host-neutral execution-only projection. It is detached from the frozen
671
809
  // launch plan and intentionally excludes layout, theme, and notifications.
672
810
  export function executionConfigFromPlan(plan) {
673
- return cloneJson({
674
- schemaVersion: 1,
811
+ return validateCaptainSessionExecutionProjection({
812
+ schemaVersion: 2,
675
813
  captain: plan.captain,
676
814
  players: plan.players.map(({ id, agent }) => ({ id, ...agent })),
677
- // Keep the complete normalized catalog. Task 7 can freeze and validate
678
- // the same identities instead of silently accepting changed module
679
- // defaults while restoring a chat-only session.
680
- catalog: plan.catalog,
815
+ catalog: Object.fromEntries(
816
+ Object.entries(plan.catalog).map(([id, item]) => [
817
+ id,
818
+ {
819
+ id: item.id,
820
+ from: item.from,
821
+ manifestCommand: item.manifestCommand,
822
+ command: item.command,
823
+ intent: item.intent,
824
+ artifactSchema: item.artifactSchema,
825
+ requiredRoleIds: item.requiredRoleIds,
826
+ concurrentRoleSets: item.concurrentRoleSets,
827
+ roles: item.roles,
828
+ options: item.options,
829
+ },
830
+ ]),
831
+ ),
681
832
  });
682
833
  }
683
834
 
684
- // PBCLI-22/23: a continuation consumes only the detached execution projection
685
- // captured at session creation. The current registry code must still expose
686
- // the recorded manifest identity, while the stored effective command remains
687
- // authoritative even when launcher configuration originally overrode it.
835
+ // PBCLI-22/23: uncertain retry consumes only its exact attempted execution
836
+ // projection. Pure record validation and structural compatibility precede the
837
+ // complete stored-catalog prepare-before-import transaction.
688
838
  export async function validateFrozenExecutionConfig(
689
- value,
839
+ structuralProjection,
840
+ executionProjection,
690
841
  { loadModule, prepareRegistryModule },
691
842
  ) {
692
- const config = requireRecord(
693
- snapshotJsonValue(value, 'Captain execution config'),
694
- 'Captain execution config',
695
- );
696
- requireExactKeys(
697
- config,
698
- ['schemaVersion', 'captain', 'players', 'catalog'],
699
- 'Captain execution config',
700
- );
701
- if (config.schemaVersion !== 1) {
702
- throw new Error(
703
- `Captain execution config schema ${JSON.stringify(config.schemaVersion)} is not supported`,
704
- );
705
- }
706
- requireRecord(config.captain, 'Captain execution config.captain');
707
- if (!Array.isArray(config.players)) {
708
- throw new Error('Captain execution config.players must be an array');
709
- }
710
- const catalog = requireRecord(
711
- config.catalog,
712
- 'Captain execution config.catalog',
843
+ const config = assertCaptainSessionExecutionCompatible(
844
+ structuralProjection,
845
+ executionProjection,
713
846
  );
714
- const catalogItems = Object.entries(catalog);
715
- if (catalogItems.length === 0) {
716
- throw new Error('Captain execution config.catalog must not be empty');
717
- }
718
-
719
- const expectedPlayerIds = [];
720
- const seenCommands = new Set();
721
- for (const [key, itemValue] of catalogItems) {
722
- const item = requireRecord(
723
- itemValue,
724
- `Captain execution config.catalog.${key}`,
725
- );
726
- const allowed = [
727
- 'id',
728
- 'from',
729
- 'manifestCommand',
730
- 'command',
731
- 'intent',
732
- 'requiredRoleIds',
733
- 'playerIds',
734
- 'options',
735
- ...(Object.hasOwn(item, 'commandOverride') ? ['commandOverride'] : []),
736
- ];
737
- requireExactKeys(
738
- item,
739
- allowed,
740
- `Captain execution config.catalog.${key}`,
741
- );
742
- if (requireNonblank(item.id, `catalog.${key}.id`) !== key) {
743
- throw new Error(
744
- `Captain execution config catalog key must equal id ${JSON.stringify(item.id)}`,
745
- );
746
- }
747
- if (item.id === 'captain') {
748
- throw new Error('Captain execution config uses the reserved playbook id "captain"');
749
- }
750
- const from = requireNonblank(item.from, `catalog.${key}.from`);
751
- if (
752
- isAbsolute(from) ||
753
- /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[A-Za-z]:[\\/])/.test(from)
754
- ) {
755
- throw new Error(
756
- `Captain execution config catalog.${key}.from is not a canonical module specifier`,
757
- );
758
- }
759
- requireNonblank(item.manifestCommand, `catalog.${key}.manifestCommand`);
760
- const command = requireNonblank(item.command, `catalog.${key}.command`);
761
- if (typeof item.intent !== 'string') {
762
- throw new Error(`catalog.${key}.intent must be a string`);
763
- }
764
- if (command === 'captain') {
765
- throw new Error(
766
- `Captain execution config catalog.${key} uses the reserved command "captain"`,
767
- );
768
- }
769
- if (seenCommands.has(command)) {
770
- throw new Error(
771
- `Captain execution config has duplicate effective command ${JSON.stringify(command)}`,
772
- );
773
- }
774
- seenCommands.add(command);
775
- if (Object.hasOwn(item, 'commandOverride')) {
776
- if (
777
- requireNonblank(item.commandOverride, `catalog.${key}.commandOverride`) !==
778
- command
779
- ) {
780
- throw new Error(`Captain execution config catalog.${key} command override is not frozen`);
781
- }
782
- } else if (command !== item.manifestCommand) {
783
- throw new Error(
784
- `Captain execution config catalog.${key} changed its manifest command without an override`,
785
- );
786
- }
787
- if (
788
- !Array.isArray(item.requiredRoleIds) ||
789
- item.requiredRoleIds.some(
790
- (role) => typeof role !== 'string' || role.trim().length === 0,
791
- ) ||
792
- new Set(item.requiredRoleIds).size !== item.requiredRoleIds.length
793
- ) {
794
- throw new Error(`Captain execution config catalog.${key}.requiredRoleIds is invalid`);
795
- }
796
- const playerIds = requireRecord(
797
- item.playerIds,
798
- `Captain execution config catalog.${key}.playerIds`,
799
- );
800
- if (Object.keys(playerIds).length === 0) {
801
- throw new Error(`Captain execution config catalog.${key}.playerIds must not be empty`);
802
- }
803
- for (const required of item.requiredRoleIds) {
804
- if (!Object.hasOwn(playerIds, required)) {
805
- throw new Error(
806
- `Captain execution config catalog.${key} has no mapped player for required role ${JSON.stringify(required)}`,
807
- );
808
- }
809
- }
810
- for (const [role, playerId] of Object.entries(playerIds)) {
811
- requireNonblank(role, `catalog.${key}.playerIds role`);
812
- if (role === 'captain') {
813
- throw new Error(`Captain execution config catalog.${key} uses the reserved role "captain"`);
814
- }
815
- requireNonblank(playerId, `catalog.${key}.playerIds.${role}`);
816
- if (playerId !== `${key}-${role}`) {
817
- throw new Error(
818
- `Captain execution config catalog.${key}.playerIds.${role} is not canonical`,
819
- );
820
- }
821
- expectedPlayerIds.push(playerId);
822
- }
823
- requireRecord(item.options, `Captain execution config catalog.${key}.options`);
824
- }
825
- if (new Set(expectedPlayerIds).size !== expectedPlayerIds.length) {
826
- throw new Error('Captain execution config maps a host player more than once');
827
- }
828
- const actualPlayerIds = config.players.map((player, index) =>
829
- requireNonblank(
830
- requireRecord(player, `Captain execution config.players[${index}]`).id,
831
- `Captain execution config.players[${index}].id`,
832
- ),
833
- );
834
- if (!isDeepStrictEqual(actualPlayerIds, expectedPlayerIds)) {
835
- throw new Error('Captain execution config players do not match the frozen catalog mapping');
836
- }
837
-
838
- // Round-trip the stored agents through the installed cligent validator. No
839
- // user config participates, and equality prevents defaults or coercions
840
- // from silently changing the frozen lineup.
841
- const firstVisible = Object.values(catalogItems[0][1].playerIds);
842
- const normalizedHost = await normalizeHostConfig({
843
- captain: {
844
- ...config.captain,
845
- from: PLAYBOOK_CAPTAIN_MODULE,
846
- options: {},
847
- },
848
- players: config.players,
849
- layout: { initialVisible: firstVisible },
850
- });
851
- const {
852
- from: _captainFrom,
853
- options: _captainOptions,
854
- ...normalizedCaptain
855
- } = normalizedHost.captain;
856
- if (
857
- !isDeepStrictEqual(normalizedCaptain, config.captain) ||
858
- !isDeepStrictEqual(normalizedHost.players, config.players)
859
- ) {
860
- throw new Error('Captain execution config agents are not canonical for this cligent host');
861
- }
847
+ const catalogItems = Object.entries(config.catalog);
862
848
 
863
849
  // Preserve the complete-catalog preparation transaction: prepare every
864
850
  // stored canonical module before importing any. A hook may provision the
@@ -896,19 +882,17 @@ export async function validateFrozenExecutionConfig(
896
882
  entry.id !== id ||
897
883
  entry.command !== item.manifestCommand ||
898
884
  entry.intent !== item.intent ||
899
- !isDeepStrictEqual(entry.requiredRoleIds, item.requiredRoleIds)
885
+ entry.artifactSchema !== item.artifactSchema ||
886
+ !isDeepStrictEqual(entry.requiredRoleIds, item.requiredRoleIds) ||
887
+ !isDeepStrictEqual(
888
+ entry.concurrentRoleSets,
889
+ item.concurrentRoleSets,
890
+ )
900
891
  ) {
901
892
  throw new Error(
902
893
  `stored playbook ${JSON.stringify(id)} no longer matches its recorded manifest identity`,
903
894
  );
904
895
  }
905
- try {
906
- entry.validateOptions(cloneJson(item.options));
907
- } catch (cause) {
908
- throw new Error(
909
- `stored playbook ${JSON.stringify(id)} options are no longer compatible: ${message(cause)}`,
910
- );
911
- }
912
896
  }
913
897
  return config;
914
898
  }
@@ -945,53 +929,38 @@ function memoizedModuleLoader(loadModule) {
945
929
  }
946
930
 
947
931
  function isValidRegistryEntry(value) {
948
- return (
949
- value !== null &&
950
- typeof value === 'object' &&
951
- !Array.isArray(value) &&
952
- typeof value.id === 'string' &&
953
- value.id.trim().length > 0 &&
954
- typeof value.command === 'string' &&
955
- value.command.trim().length > 0 &&
956
- typeof value.intent === 'string' &&
957
- Array.isArray(value.requiredRoleIds) &&
958
- value.requiredRoleIds.every(
959
- (role) => typeof role === 'string' && role.trim().length > 0,
960
- ) &&
961
- new Set(value.requiredRoleIds).size === value.requiredRoleIds.length &&
962
- typeof value.validateOptions === 'function' &&
963
- typeof value.createRuntime === 'function'
964
- );
965
- }
966
-
967
- function requireRecord(value, path) {
968
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
969
- throw new Error(`${path} must be an object`);
970
- }
971
- return value;
972
- }
973
-
974
- function requireExactKeys(value, expected, path) {
975
- const keys = Object.keys(value);
976
- const allowed = new Set(expected);
977
- const unknown = keys.find((key) => !allowed.has(key));
978
- if (unknown !== undefined) {
979
- throw new Error(`${path} has unknown field ${JSON.stringify(unknown)}`);
980
- }
981
- const missing = expected.find((key) => !Object.hasOwn(value, key));
982
- if (missing !== undefined) {
983
- throw new Error(`${path} is missing field ${JSON.stringify(missing)}`);
984
- }
985
- }
986
-
987
- function requireNonblank(value, path) {
988
- if (typeof value !== 'string' || value.trim().length === 0) {
989
- throw new Error(`${path} must be a nonblank string`);
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;
990
952
  }
991
- return value;
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
+ );
992
961
  }
993
962
 
994
- function captainOptionsFromConfig(config) {
963
+ export function captainOptionsFromConfig(config) {
995
964
  return {
996
965
  playbooks: Object.fromEntries(
997
966
  Object.entries(config.catalog).map(([id, item]) => [
@@ -999,11 +968,21 @@ function captainOptionsFromConfig(config) {
999
968
  {
1000
969
  from: item.from,
1001
970
  command: item.command,
971
+ roles: cloneJson(item.roles),
1002
972
  options: cloneJson(item.options),
1003
973
  },
1004
974
  ]),
1005
975
  ),
1006
- captainAdapter: config.captain.adapter,
976
+ sessionAgents: {
977
+ captain: cloneJson(config.captain),
978
+ players: Object.fromEntries(
979
+ config.players.map(({ id, ...agent }) => [id, cloneJson(agent)]),
980
+ ),
981
+ },
982
+ ...(typeof config.captain.adapter === 'string' &&
983
+ config.captain.adapter.length > 0
984
+ ? { captainAdapter: config.captain.adapter }
985
+ : {}),
1007
986
  };
1008
987
  }
1009
988
 
@@ -1136,10 +1115,10 @@ export function parseRunArgs(argv) {
1136
1115
  );
1137
1116
  }
1138
1117
  if (
1139
- (parsed.continue || parsed.sessionId !== undefined) &&
1118
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1140
1119
  parsed.withPaths.length > 0
1141
1120
  ) {
1142
- throw new Error('--with cannot change a frozen continued Captain session');
1121
+ throw new Error('--with is unavailable during uncertain-turn recovery');
1143
1122
  }
1144
1123
  if (
1145
1124
  parsed.sessionId !== undefined &&
@@ -1320,8 +1299,8 @@ function runHelpText(userConfigPath) {
1320
1299
  'Usage:',
1321
1300
  ' playbook run [--with <path>]... [--no-provision] [--json]',
1322
1301
  ' [--verbose] [--] [input]',
1323
- ' playbook run (--continue | --session <id>) [--no-provision]',
1324
- ' [--json] [--verbose] [--] [reply]',
1302
+ ' playbook run (--continue | --session <id>) [--with <path>]...',
1303
+ ' [--no-provision] [--json] [--verbose] [--] [reply]',
1325
1304
  ' playbook run --session <id> --retry-uncertain [--no-provision]',
1326
1305
  ' playbook run --session <id> --discard-uncertain',
1327
1306
  '',
@@ -1335,8 +1314,14 @@ function runHelpText(userConfigPath) {
1335
1314
  '`playbook`. Enable an external registry in that config, then invoke its',
1336
1315
  'effective /command through Captain. The former positional registry,',
1337
1316
  'resume, and run-only binding surfaces have been removed.',
1338
- 'A continued run restores the stored execution config and working',
1339
- 'directory; it never re-reads current config or --with overlays.',
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.',
1340
1325
  '',
1341
1326
  'Options:',
1342
1327
  ' --with <path> overlay a generic config fragment (repeatable)',