@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.
- package/README.md +20 -7
- package/docs/cli.md +88 -43
- package/docs/configuration.md +221 -119
- package/docs/embedding.md +78 -27
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +18 -15
- package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +25 -15
- package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
- package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
- package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +29 -23
- package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +45 -24
- package/slc/link.md +297 -135
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +21 -16
- package/src/runtime.ts +20 -23
- package/src/xstate-playbook-runtime.d.ts +34 -20
- package/src/xstate-playbook-runtime.js +973 -400
- package/src/xstate-playbook-runtime.ts +1203 -457
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +198 -81
- 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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|
|
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
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
674
|
-
schemaVersion:
|
|
811
|
+
return validateCaptainSessionExecutionProjection({
|
|
812
|
+
schemaVersion: 2,
|
|
675
813
|
captain: plan.captain,
|
|
676
814
|
players: plan.players.map(({ id, agent }) => ({ id, ...agent })),
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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:
|
|
685
|
-
//
|
|
686
|
-
//
|
|
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
|
-
|
|
839
|
+
structuralProjection,
|
|
840
|
+
executionProjection,
|
|
690
841
|
{ loadModule, prepareRegistryModule },
|
|
691
842
|
) {
|
|
692
|
-
const config =
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
value
|
|
950
|
-
typeof value
|
|
951
|
-
|
|
952
|
-
typeof value.id
|
|
953
|
-
value.id.trim().length
|
|
954
|
-
typeof value.command
|
|
955
|
-
value.command.trim().length
|
|
956
|
-
typeof value.intent
|
|
957
|
-
|
|
958
|
-
value.requiredRoleIds
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1118
|
+
(parsed.retryUncertain || parsed.discardUncertain) &&
|
|
1140
1119
|
parsed.withPaths.length > 0
|
|
1141
1120
|
) {
|
|
1142
|
-
throw new Error('--with
|
|
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>) [--
|
|
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
|
-
'
|
|
1339
|
-
'
|
|
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)',
|