@sublang/playbook 13.2.0 → 14.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 +5 -5
- package/docs/configuration.md +14 -9
- package/docs/embedding.md +20 -1
- package/package.json +6 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
- package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +11 -3
- package/reference/sdlc/code.playbook/bin/run.js +4 -0
- package/reference/sdlc/code.playbook/bin/session-host.js +13 -3
- package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +26 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +233 -8
- package/reference/sdlc/code.playbook/playbook-captain.ts +298 -8
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +5 -6
- package/reference/sdlc/code.playbook/session-host.d.ts +26 -0
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +5 -5
- package/reference/sdlc/dev.playbook/dev.playbook.ts +7 -7
- package/reference/sdlc/pr.md +17 -4
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +17 -3
- package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
- package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
- package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
- package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
- package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
- package/slc/experiments/fsm-scaffold-guidance.md +31 -0
- package/slc/gears2fsm.md +145 -58
- package/slc/link.md +158 -7
- package/slc/materialize-link.mjs +460 -0
- package/slc/optimize.md +46 -6
- package/slc/scaffold-fsm.mjs +225 -0
- package/slc/slc.pin-inputs.json +13 -0
- package/slc/text2gears.md +38 -3
- package/slc/workflow-contracts.json +371 -0
- package/src/xstate-playbook-runtime.js +35 -1
- package/src/xstate-playbook-runtime.ts +54 -1
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
PlaybookCallRequest,
|
|
25
25
|
PlaybookCallResult,
|
|
26
26
|
PlaybookCallStart,
|
|
27
|
+
PlaybookControlAction,
|
|
27
28
|
PlaybookControlView,
|
|
28
29
|
PlaybookPorts,
|
|
29
30
|
PlaybookRunResult,
|
|
@@ -353,6 +354,31 @@ export interface PlaybookCaptainShell extends Captain {
|
|
|
353
354
|
session: CaptainSession,
|
|
354
355
|
snapshot: PlaybookCaptainShellSnapshot,
|
|
355
356
|
): Promise<void>;
|
|
357
|
+
/**
|
|
358
|
+
* The active leaf's currently advertised runtime actions, as the host's own
|
|
359
|
+
* reading of the control view the ControlView digest reads (CAPTAIN-60).
|
|
360
|
+
* Declared optional because capability absence is member absence: a shell
|
|
361
|
+
* that publishes no reader advertises nothing.
|
|
362
|
+
*/
|
|
363
|
+
describeRuntimeActions?(): readonly PlaybookControlAction[];
|
|
364
|
+
/**
|
|
365
|
+
* Select one currently advertised runtime action as the next Boss turn's
|
|
366
|
+
* decision and return that turn's Boss text, which the host submits through
|
|
367
|
+
* `handleBossTurn` (CAPTAIN-7).
|
|
368
|
+
*/
|
|
369
|
+
submitRuntimeAction?(actionId: string): string;
|
|
370
|
+
/**
|
|
371
|
+
* The controls the shell effects on its own behalf, disjoint from the leaf's
|
|
372
|
+
* advertised runtime actions (CAPTAIN-62). Declared optional for the same
|
|
373
|
+
* reason: a shell that publishes no reader advertises nothing.
|
|
374
|
+
*/
|
|
375
|
+
describeShellActions?(): readonly PlaybookControlAction[];
|
|
376
|
+
/**
|
|
377
|
+
* Select one currently advertised shell control as the next Boss turn's
|
|
378
|
+
* decision and return that turn's Boss text, which the host submits through
|
|
379
|
+
* `handleBossTurn` (CAPTAIN-7).
|
|
380
|
+
*/
|
|
381
|
+
submitShellAction?(actionId: string): string;
|
|
356
382
|
}
|
|
357
383
|
|
|
358
384
|
// Per-enabled-playbook binding the shell resolves at init from the exact
|
|
@@ -575,6 +601,9 @@ function isCanonicalLocalRoleId(value: unknown): value is string {
|
|
|
575
601
|
const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
|
|
576
602
|
'reconcile:unresolved-effect';
|
|
577
603
|
const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
|
|
604
|
+
// CAPTAIN-62: the shell's own control, not a runtime action. The id is machine
|
|
605
|
+
// data the host echoes back; the Boss never sees it (CAPPLAY-5).
|
|
606
|
+
const GIVE_UP_ACTION_ID = 'give-up';
|
|
578
607
|
// The fixed machine-syntax guard of a player's Boss-question suspension
|
|
579
608
|
// (slc/link.md §Boss-reply suspension): the accepted outcome that parks on
|
|
580
609
|
// Boss rather than sparing Boss a relay.
|
|
@@ -608,6 +637,19 @@ interface ActiveTurn {
|
|
|
608
637
|
*/
|
|
609
638
|
readonly authoritativeText: string;
|
|
610
639
|
readonly resolution?: CaptainParsedResolution;
|
|
640
|
+
/**
|
|
641
|
+
* The advertised action id the host selected for this turn (CAPTAIN-7). Its
|
|
642
|
+
* presence is the turn's decision: the decision state receives that
|
|
643
|
+
* selection from the shell and allocates no decision call.
|
|
644
|
+
*/
|
|
645
|
+
readonly hostRuntimeActionId?: string;
|
|
646
|
+
/**
|
|
647
|
+
* Set where the host decided this turn by selecting the shell's own give-up
|
|
648
|
+
* control (CAPTAIN-62). Like `hostRuntimeActionId` its presence is the
|
|
649
|
+
* turn's decision, and it additionally settles the result phase in the shell
|
|
650
|
+
* so the turn makes no model call at all (CAPTAIN-63).
|
|
651
|
+
*/
|
|
652
|
+
readonly hostGiveUp?: true;
|
|
611
653
|
/** A settlement with status `ok` is final for the turn (DR-029). */
|
|
612
654
|
settled: boolean;
|
|
613
655
|
/**
|
|
@@ -3118,6 +3160,15 @@ export function createPlaybookCaptainShell(
|
|
|
3118
3160
|
outcome: DurableCallOutcome;
|
|
3119
3161
|
}
|
|
3120
3162
|
| undefined;
|
|
3163
|
+
// CAPTAIN-7: the control the host selected for the turn it is about to
|
|
3164
|
+
// submit, held with the exact Boss text that turn carries so an unrelated
|
|
3165
|
+
// turn never consumes the selection. One turn consumes it, whichever it is,
|
|
3166
|
+
// and one slot holds it whichever surface it came from — the leaf's
|
|
3167
|
+
// advertised actions or the shell's own controls (CAPTAIN-62).
|
|
3168
|
+
let pendingHostSelection:
|
|
3169
|
+
| { readonly kind: 'runtime'; readonly actionId: string; readonly text: string }
|
|
3170
|
+
| { readonly kind: 'give-up'; readonly text: string }
|
|
3171
|
+
| undefined;
|
|
3121
3172
|
let lastAction: ControllerAction | undefined;
|
|
3122
3173
|
let lastSettlementStatus: SettlementEvidence['status'] | undefined;
|
|
3123
3174
|
// DR-038 §2: a turn can dispose its root before the durable caller asks
|
|
@@ -5178,6 +5229,7 @@ export function createPlaybookCaptainShell(
|
|
|
5178
5229
|
const settleUnresolvedEffectAbandonment = async (
|
|
5179
5230
|
unresolvedLeaf: EngagementFrame,
|
|
5180
5231
|
): Promise<void> => {
|
|
5232
|
+
abandonmentSettlementUnsafe = true;
|
|
5181
5233
|
if (leafFrame() !== unresolvedLeaf) {
|
|
5182
5234
|
throw new Error(
|
|
5183
5235
|
'unresolved-effect abandonment requires the active leaf',
|
|
@@ -5212,6 +5264,7 @@ export function createPlaybookCaptainShell(
|
|
|
5212
5264
|
rootPlaybookId,
|
|
5213
5265
|
});
|
|
5214
5266
|
await runEffect(() => unresolvedEffectSettlement.complete(settlement));
|
|
5267
|
+
abandonmentSettlementUnsafe = false;
|
|
5215
5268
|
};
|
|
5216
5269
|
|
|
5217
5270
|
const callResultFor = (
|
|
@@ -5900,6 +5953,132 @@ export function createPlaybookCaptainShell(
|
|
|
5900
5953
|
return lines.join('\n');
|
|
5901
5954
|
};
|
|
5902
5955
|
|
|
5956
|
+
// CAPTAIN-60: the same reading the ControlView digest performs, published as
|
|
5957
|
+
// data instead of prose. It states what the digest states and withholds what
|
|
5958
|
+
// the digest withholds: an idle shell, a leaf with no control surface, and a
|
|
5959
|
+
// control view that cannot be read all advertise nothing, and a leaf fenced
|
|
5960
|
+
// for repository-effect reconciliation advertises only its fence controls.
|
|
5961
|
+
// The pairs are detached and frozen, so a host holds the shell's reading
|
|
5962
|
+
// rather than a live view the runtime can rewrite underneath it.
|
|
5963
|
+
const advertisedRuntimeActions = (): readonly PlaybookControlAction[] => {
|
|
5964
|
+
const empty: readonly PlaybookControlAction[] = Object.freeze([]);
|
|
5965
|
+
if (lifecycle !== 'ready' || terminallyDisposed) return empty;
|
|
5966
|
+
// Between turns: while a turn is running, the leaf's control view is the
|
|
5967
|
+
// decision's grounding and the host can act on no selection, so the shell
|
|
5968
|
+
// publishes none and reads nothing from a runtime mid-run.
|
|
5969
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
5970
|
+
return empty;
|
|
5971
|
+
}
|
|
5972
|
+
const leaf = leafFrame();
|
|
5973
|
+
if (!leaf || typeof leaf.runtime.describe !== 'function') return empty;
|
|
5974
|
+
refreshRetainedEffectFence();
|
|
5975
|
+
const fenced = retainedEffectReconciliation !== undefined;
|
|
5976
|
+
if (fenced && typeof leaf.runtime.apply !== 'function') return empty;
|
|
5977
|
+
let actions: readonly PlaybookControlAction[];
|
|
5978
|
+
try {
|
|
5979
|
+
actions = leaf.runtime.describe().actions;
|
|
5980
|
+
} catch {
|
|
5981
|
+
// An unreadable control view advertises nothing.
|
|
5982
|
+
return empty;
|
|
5983
|
+
}
|
|
5984
|
+
return Object.freeze(
|
|
5985
|
+
actions
|
|
5986
|
+
.filter(
|
|
5987
|
+
(action) =>
|
|
5988
|
+
!fenced ||
|
|
5989
|
+
action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
5990
|
+
action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
|
|
5991
|
+
)
|
|
5992
|
+
.map((action) => Object.freeze({ id: action.id, label: action.label })),
|
|
5993
|
+
);
|
|
5994
|
+
};
|
|
5995
|
+
|
|
5996
|
+
// CAPTAIN-62: the controls the shell effects itself, published beside the
|
|
5997
|
+
// leaf's advertised actions and never mixed with them — these are the
|
|
5998
|
+
// shell's own, so an engaged root offers one whatever its leaf advertises,
|
|
5999
|
+
// fenced or not. A fenced root keeps its control because giving up reports
|
|
6000
|
+
// unresolved effects (CAPTAIN-58) rather than reconciling them, and a run
|
|
6001
|
+
// whose effects are unknown is exactly the run a Boss gives up on.
|
|
6002
|
+
const advertisedShellActions = (): readonly PlaybookControlAction[] => {
|
|
6003
|
+
const empty: readonly PlaybookControlAction[] = Object.freeze([]);
|
|
6004
|
+
if (lifecycle !== 'ready' || terminallyDisposed) return empty;
|
|
6005
|
+
// Between turns, on the same rule the leaf's actions take: mid-turn the
|
|
6006
|
+
// host can act on no selection.
|
|
6007
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
6008
|
+
return empty;
|
|
6009
|
+
}
|
|
6010
|
+
const root = rootFrame();
|
|
6011
|
+
if (!root) return empty;
|
|
6012
|
+
return Object.freeze([
|
|
6013
|
+
Object.freeze({
|
|
6014
|
+
id: GIVE_UP_ACTION_ID,
|
|
6015
|
+
// The label is the turn's durable Boss text, so it names the run the
|
|
6016
|
+
// way the Boss started it rather than the frame that happens to be
|
|
6017
|
+
// parked inside it.
|
|
6018
|
+
label: `Stop ${frameLabel(root)}`,
|
|
6019
|
+
}),
|
|
6020
|
+
]);
|
|
6021
|
+
};
|
|
6022
|
+
|
|
6023
|
+
// CAPTAIN-7: the host's selection of the shell's own control, validated here
|
|
6024
|
+
// against the same live reading. The returned text is the turn the host
|
|
6025
|
+
// submits, exactly as for an advertised runtime action.
|
|
6026
|
+
const selectShellAction = (actionId: string): string => {
|
|
6027
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
6028
|
+
throw new Error(
|
|
6029
|
+
'a shell control requires an initialized or restored Captain shell',
|
|
6030
|
+
);
|
|
6031
|
+
}
|
|
6032
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
6033
|
+
throw new Error('a shell control cannot be selected during a Boss turn');
|
|
6034
|
+
}
|
|
6035
|
+
if (typeof actionId !== 'string' || actionId.length === 0) {
|
|
6036
|
+
throw new TypeError('a shell control id must be a nonempty string');
|
|
6037
|
+
}
|
|
6038
|
+
const advertised = advertisedShellActions().find(
|
|
6039
|
+
(action) => action.id === actionId,
|
|
6040
|
+
);
|
|
6041
|
+
if (advertised === undefined) {
|
|
6042
|
+
throw new Error('the shell does not advertise that control');
|
|
6043
|
+
}
|
|
6044
|
+
pendingHostSelection = { kind: 'give-up', text: advertised.label };
|
|
6045
|
+
return advertised.label;
|
|
6046
|
+
};
|
|
6047
|
+
|
|
6048
|
+
// CAPTAIN-7: the host's own selection of one advertised action, validated
|
|
6049
|
+
// here against the same live reading and validated again at the controller
|
|
6050
|
+
// port before any effect. The returned text is the turn the host submits.
|
|
6051
|
+
const selectRuntimeAction = (actionId: string): string => {
|
|
6052
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
6053
|
+
throw new Error(
|
|
6054
|
+
'a runtime action requires an initialized or restored Captain shell',
|
|
6055
|
+
);
|
|
6056
|
+
}
|
|
6057
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
6058
|
+
throw new Error('a runtime action cannot be selected during a Boss turn');
|
|
6059
|
+
}
|
|
6060
|
+
if (typeof actionId !== 'string' || actionId.length === 0) {
|
|
6061
|
+
throw new TypeError('a runtime action id must be a nonempty string');
|
|
6062
|
+
}
|
|
6063
|
+
const advertised = advertisedRuntimeActions().find(
|
|
6064
|
+
(action) => action.id === actionId,
|
|
6065
|
+
);
|
|
6066
|
+
if (advertised === undefined) {
|
|
6067
|
+
// CAPPLAY-5: the chosen id is control data whether or not the leaf
|
|
6068
|
+
// advertises it, so the refusal names the fact and not the string.
|
|
6069
|
+
throw new Error('the active leaf does not advertise that action');
|
|
6070
|
+
}
|
|
6071
|
+
const text =
|
|
6072
|
+
typeof advertised.label === 'string' ? advertised.label.trim() : '';
|
|
6073
|
+
if (text.length === 0) {
|
|
6074
|
+
throw new Error(
|
|
6075
|
+
'the advertised action carries no Boss-facing label to submit as its turn',
|
|
6076
|
+
);
|
|
6077
|
+
}
|
|
6078
|
+
pendingHostSelection = { kind: 'runtime', actionId, text };
|
|
6079
|
+
return text;
|
|
6080
|
+
};
|
|
6081
|
+
|
|
5903
6082
|
// The catalog is registry-authored, not shell-authored: an id, a command,
|
|
5904
6083
|
// and an intent all arrive from an enabled module. They pass the same seam
|
|
5905
6084
|
// the ControlView lines do, so an intent carrying a newline cannot open a
|
|
@@ -6471,6 +6650,42 @@ export function createPlaybookCaptainShell(
|
|
|
6471
6650
|
? []
|
|
6472
6651
|
: [labeledBlock('Conversation recap', options.reseedDigest)]),
|
|
6473
6652
|
]);
|
|
6653
|
+
// CAPTAIN-7: a turn whose decision the host already made allocates no
|
|
6654
|
+
// decision call. The host's selection is this state's decision reply, so
|
|
6655
|
+
// it passes the runtime's own reply validation and reaches the shell
|
|
6656
|
+
// through the controller port like every other selection — validation,
|
|
6657
|
+
// execution, the outcome report, and the closing reply are the loop's.
|
|
6658
|
+
if (kind === 'decision' && turn?.hostRuntimeActionId !== undefined) {
|
|
6659
|
+
return {
|
|
6660
|
+
status: 'ok' as const,
|
|
6661
|
+
finalText: JSON.stringify({
|
|
6662
|
+
action: 'runtime',
|
|
6663
|
+
actionId: turn.hostRuntimeActionId,
|
|
6664
|
+
}),
|
|
6665
|
+
};
|
|
6666
|
+
}
|
|
6667
|
+
// CAPTAIN-63: a give-up allocates neither call. Its decision is the
|
|
6668
|
+
// host's, and its result phase is answered below from the settlement the
|
|
6669
|
+
// shell already holds, so an exit from a failing provider does not ask
|
|
6670
|
+
// that provider for permission.
|
|
6671
|
+
if (kind === 'decision' && turn?.hostGiveUp === true) {
|
|
6672
|
+
return {
|
|
6673
|
+
status: 'ok' as const,
|
|
6674
|
+
finalText: JSON.stringify({ action: 'dismiss' }),
|
|
6675
|
+
};
|
|
6676
|
+
}
|
|
6677
|
+
if (kind === 'closingReply' && turn?.hostGiveUp === true) {
|
|
6678
|
+
// The durable conversation did not receive this turn, so it is marked
|
|
6679
|
+
// for the CAPTAIN-35 catch-up rather than left believing it is
|
|
6680
|
+
// current: the next call carries the journal records it missed.
|
|
6681
|
+
markConversationCatchUp();
|
|
6682
|
+
// Through the one presentation seam, exactly where a model-composed
|
|
6683
|
+
// reply is presented, so this reply is journaled and single-attempt
|
|
6684
|
+
// like every other. It needs no reply validation or corrective re-ask:
|
|
6685
|
+
// `giveUpReplyText` already validated what it composed.
|
|
6686
|
+
await surfaceSettlement({ context, text: giveUpReplyText() });
|
|
6687
|
+
return { status: 'ok' as const, finalText: 'ok' };
|
|
6688
|
+
}
|
|
6474
6689
|
const outcome = await durableCall(context, compose);
|
|
6475
6690
|
if (kind === 'decision') {
|
|
6476
6691
|
// A model-decided `respond` surfaces this call's own prose, so the
|
|
@@ -6700,9 +6915,26 @@ export function createPlaybookCaptainShell(
|
|
|
6700
6915
|
const root = rootFrame();
|
|
6701
6916
|
if (!root) return false;
|
|
6702
6917
|
const label = frameLabel(root);
|
|
6918
|
+
if (
|
|
6919
|
+
activeTurn?.hostGiveUp === true &&
|
|
6920
|
+
freezeTurnUnresolvedEffects().length > 0
|
|
6921
|
+
) {
|
|
6922
|
+
await settleUnresolvedEffectAbandonment(leafFrame()!);
|
|
6923
|
+
facts.push(`Dismissed the ${label} engagement with unresolved repository effects reported.`);
|
|
6924
|
+
return false;
|
|
6925
|
+
}
|
|
6703
6926
|
// Dismissal leaves the procedure unfinished. Persist the latest safe
|
|
6704
|
-
// generation captured for this turn before disposal erases the frames
|
|
6705
|
-
|
|
6927
|
+
// generation captured for this turn before disposal erases the frames —
|
|
6928
|
+
// unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
|
|
6929
|
+
// would offer the abandoned run back on the idle digest's next reading.
|
|
6930
|
+
if (activeTurn?.hostGiveUp === true) {
|
|
6931
|
+
pendingRetentionUpdates.set(root.entry.id, {
|
|
6932
|
+
kind: 'clear',
|
|
6933
|
+
rootPlaybookId: root.entry.id,
|
|
6934
|
+
});
|
|
6935
|
+
} else {
|
|
6936
|
+
retainOrClearDisposedRoot(root);
|
|
6937
|
+
}
|
|
6706
6938
|
try {
|
|
6707
6939
|
await runEffect(() => disposeStack('dismiss'));
|
|
6708
6940
|
facts.push(`Dismissed the ${label} engagement.`);
|
|
@@ -7146,7 +7378,8 @@ export function createPlaybookCaptainShell(
|
|
|
7146
7378
|
fencedLeaf !== undefined;
|
|
7147
7379
|
if (
|
|
7148
7380
|
retainedEffectReconciliation !== undefined &&
|
|
7149
|
-
!routesRetainedReconciliation
|
|
7381
|
+
!routesRetainedReconciliation &&
|
|
7382
|
+
!(selection.action === 'dismiss' && turn.hostGiveUp === true)
|
|
7150
7383
|
) {
|
|
7151
7384
|
return rejectSelection(
|
|
7152
7385
|
selection,
|
|
@@ -7276,10 +7509,16 @@ export function createPlaybookCaptainShell(
|
|
|
7276
7509
|
if (!leaf) {
|
|
7277
7510
|
return rejectSelection(selection, 'no engagement is active to dismiss');
|
|
7278
7511
|
}
|
|
7512
|
+
// CAPTAIN-63: a give-up ends the run. An ordinary dismissal of a nested
|
|
7513
|
+
// call returns to its caller, which is the right reading of "stop this
|
|
7514
|
+
// call" and the wrong one of "stop this workflow" — the Boss gave up on
|
|
7515
|
+
// the root, not on whichever child was parked when they did.
|
|
7516
|
+
const giveUp = turn.hostGiveUp === true;
|
|
7517
|
+
const dismissed = giveUp ? (rootFrame() ?? leaf) : leaf;
|
|
7279
7518
|
turn.settled = true;
|
|
7280
|
-
journalAction({ action: 'dismiss', playbookId:
|
|
7281
|
-
const label = frameLabel(
|
|
7282
|
-
if (leaf.parent) {
|
|
7519
|
+
journalAction({ action: 'dismiss', playbookId: dismissed.entry.id });
|
|
7520
|
+
const label = frameLabel(dismissed);
|
|
7521
|
+
if (leaf.parent && !giveUp) {
|
|
7283
7522
|
// No boundary around the return itself: `resumeParent` disposes the
|
|
7284
7523
|
// child and drives the parent, and each of those is marked where it
|
|
7285
7524
|
// happens. A visibility rejection raised on the way back is shell
|
|
@@ -7498,7 +7737,6 @@ export function createPlaybookCaptainShell(
|
|
|
7498
7737
|
try {
|
|
7499
7738
|
await settleUnresolvedEffectAbandonment(leaf);
|
|
7500
7739
|
} catch (error) {
|
|
7501
|
-
abandonmentSettlementUnsafe = true;
|
|
7502
7740
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
7503
7741
|
name: 'Error',
|
|
7504
7742
|
message: String(error),
|
|
@@ -7615,6 +7853,34 @@ export function createPlaybookCaptainShell(
|
|
|
7615
7853
|
// The reply composes from the authoritative report, never the early
|
|
7616
7854
|
// `settled` guard. That guard closes duplicate submissions before an effect
|
|
7617
7855
|
// starts; it is not evidence that anything ran.
|
|
7856
|
+
// CAPTAIN-63: the give-up's closing reply, composed from the same settlement
|
|
7857
|
+
// facts the result-phase prompt would have carried. Shell-authored Boss
|
|
7858
|
+
// prose passes the validation every model reply passes (CAPTAIN-34); what it
|
|
7859
|
+
// interpolates is not host-authored all the way down, so a fact set that
|
|
7860
|
+
// fails validation is dropped rather than spoken and the reply still states
|
|
7861
|
+
// the settlement truthfully.
|
|
7862
|
+
const giveUpReplyText = (): string => {
|
|
7863
|
+
const report = activeTurn?.report;
|
|
7864
|
+
const facts = report?.bossFacts ?? report?.facts ?? [];
|
|
7865
|
+
const stopped = report?.status === 'ok';
|
|
7866
|
+
const opening = stopped
|
|
7867
|
+
? 'Stopped that workflow. I will not resume it.'
|
|
7868
|
+
: 'I could not confirm that the workflow was stopped.';
|
|
7869
|
+
const closing = stopped
|
|
7870
|
+
? 'Send the command again to start fresh work.'
|
|
7871
|
+
: 'The stop did not settle successfully; recover the session before continuing.';
|
|
7872
|
+
const composed = [
|
|
7873
|
+
opening,
|
|
7874
|
+
...(facts.length === 0
|
|
7875
|
+
? []
|
|
7876
|
+
: ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
|
|
7877
|
+
closing,
|
|
7878
|
+
].join('\n');
|
|
7879
|
+
return replyRejection(composed) === undefined
|
|
7880
|
+
? composed
|
|
7881
|
+
: [opening, closing].join('\n');
|
|
7882
|
+
};
|
|
7883
|
+
|
|
7618
7884
|
const failureReplyText = (): string => {
|
|
7619
7885
|
const commands = [...enablementById.values()]
|
|
7620
7886
|
.map((enablement) => `/${enablement.command} <task>`)
|
|
@@ -8685,6 +8951,14 @@ export function createPlaybookCaptainShell(
|
|
|
8685
8951
|
|
|
8686
8952
|
installRetainedGenerations,
|
|
8687
8953
|
|
|
8954
|
+
describeRuntimeActions: advertisedRuntimeActions,
|
|
8955
|
+
|
|
8956
|
+
submitRuntimeAction: selectRuntimeAction,
|
|
8957
|
+
|
|
8958
|
+
describeShellActions: advertisedShellActions,
|
|
8959
|
+
|
|
8960
|
+
submitShellAction: selectShellAction,
|
|
8961
|
+
|
|
8688
8962
|
async handleBossTurn(
|
|
8689
8963
|
turn: BossTurn,
|
|
8690
8964
|
context: CaptainContext,
|
|
@@ -8724,13 +8998,28 @@ export function createPlaybookCaptainShell(
|
|
|
8724
8998
|
const turnHostCalls = new Set<Promise<unknown>>();
|
|
8725
8999
|
activeTurnHostCalls = turnHostCalls;
|
|
8726
9000
|
activeContext = context;
|
|
8727
|
-
|
|
9001
|
+
// CAPTAIN-7: one host selection is consumed by one turn. It decides only
|
|
9002
|
+
// the turn carrying the exact text the selection was made for; any other
|
|
9003
|
+
// turn drops it and resolves normally.
|
|
9004
|
+
const selected = pendingHostSelection;
|
|
9005
|
+
pendingHostSelection = undefined;
|
|
9006
|
+
const decided =
|
|
9007
|
+
selected !== undefined && selected.text === turn.prompt
|
|
9008
|
+
? selected
|
|
9009
|
+
: undefined;
|
|
9010
|
+
const hostRuntimeActionId =
|
|
9011
|
+
decided?.kind === 'runtime' ? decided.actionId : undefined;
|
|
9012
|
+
const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
|
|
9013
|
+
const parsed =
|
|
9014
|
+
decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
|
|
8728
9015
|
activeTurn = {
|
|
8729
9016
|
id: ++turnSequence,
|
|
8730
9017
|
captainSyncedJournalSeq: journalSeq,
|
|
8731
9018
|
bossText: turn.prompt,
|
|
8732
9019
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
8733
9020
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
9021
|
+
...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
|
|
9022
|
+
...(hostGiveUp === undefined ? {} : { hostGiveUp }),
|
|
8734
9023
|
settled: false,
|
|
8735
9024
|
presentationAttempted: false,
|
|
8736
9025
|
settlementFacts: [],
|
|
@@ -8880,6 +9169,7 @@ export function createPlaybookCaptainShell(
|
|
|
8880
9169
|
failure ??= error;
|
|
8881
9170
|
}
|
|
8882
9171
|
}
|
|
9172
|
+
pendingHostSelection = undefined;
|
|
8883
9173
|
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
8884
9174
|
// its ownership after every frame host call and the Captain are drained.
|
|
8885
9175
|
playerTransactions.clear();
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
# Drop an adapter from this file and you can skip its SDK entirely.
|
|
22
22
|
# Every seeded agent runs in cligent's protected auto mode
|
|
23
23
|
# (permissions.mode: auto): claude maps it to permissionMode auto, codex to
|
|
24
|
-
# on-request + auto_review.
|
|
24
|
+
# on-request + auto_review. A codex agent additionally needs
|
|
25
|
+
# writablePaths: ['.git'] to write git metadata under its sandbox.
|
|
25
26
|
# so they can inspect and work with repository metadata under the sandbox.
|
|
26
27
|
|
|
27
28
|
# The Captain/Judge agent.
|
|
@@ -46,15 +47,13 @@ notifications:
|
|
|
46
47
|
# shares one provider conversation across those playbooks.
|
|
47
48
|
players:
|
|
48
49
|
dev.coder:
|
|
49
|
-
adapter:
|
|
50
|
-
model:
|
|
51
|
-
effort:
|
|
50
|
+
adapter: claude
|
|
51
|
+
model: claude-opus-5
|
|
52
|
+
effort: high
|
|
52
53
|
# Adapter-scoped fast mode is validated by the installed Cligent contract;
|
|
53
54
|
# omitting it takes the provider default.
|
|
54
|
-
fastMode: true
|
|
55
55
|
permissions:
|
|
56
56
|
mode: auto
|
|
57
|
-
writablePaths: ['.git']
|
|
58
57
|
|
|
59
58
|
dev.reviewer:
|
|
60
59
|
adapter: claude
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
|
+
import type { PlaybookControlAction } from '@sublang/playbook/runtime';
|
|
4
5
|
import type { PlaybookCaptainShell, PlaybookCaptainShellSnapshot } from './playbook-captain.js';
|
|
5
6
|
import type { PlaybookEffectLedger } from './host-capabilities.js';
|
|
6
7
|
import type { PlaybookSessionLifecycle, SessionExecutionProjection, SessionStructuralProjection, SessionFreshBoundary, SessionRecovery } from './session-store.js';
|
|
@@ -68,6 +69,31 @@ export interface SessionHostController {
|
|
|
68
69
|
readonly lease: PlaybookSessionLifecycle;
|
|
69
70
|
read(): Promise<SessionRecovery | undefined>;
|
|
70
71
|
handleBossTurn(input: string): Promise<SessionRecovery>;
|
|
72
|
+
/**
|
|
73
|
+
* The active leaf's currently advertised runtime actions, read live between
|
|
74
|
+
* turns; empty whenever nothing is advertised, a turn is active, or the
|
|
75
|
+
* leaf's control view cannot be read.
|
|
76
|
+
*/
|
|
77
|
+
listRuntimeActions(): readonly PlaybookControlAction[];
|
|
78
|
+
/**
|
|
79
|
+
* Submit one currently advertised runtime action as the session's next turn.
|
|
80
|
+
* The turn is the same durable transaction `handleBossTurn` runs, and its
|
|
81
|
+
* decision is the submitted selection, so no decision model call is made.
|
|
82
|
+
*/
|
|
83
|
+
submitRuntimeAction(actionId: string): Promise<SessionRecovery>;
|
|
84
|
+
/**
|
|
85
|
+
* The controls the shell offers on its own behalf, read live between turns;
|
|
86
|
+
* today one give-up while a root is engaged, and empty while idle or while a
|
|
87
|
+
* turn is active. Disjoint from `listRuntimeActions`, which reads the leaf.
|
|
88
|
+
*/
|
|
89
|
+
listShellActions(): readonly PlaybookControlAction[];
|
|
90
|
+
/**
|
|
91
|
+
* Submit one currently advertised shell control as the session's next turn.
|
|
92
|
+
* The turn is the same durable transaction `handleBossTurn` runs; both its
|
|
93
|
+
* decision and its closing reply are the shell's, so the turn makes no model
|
|
94
|
+
* call and a give-up survives a provider that is refusing everything.
|
|
95
|
+
*/
|
|
96
|
+
submitShellAction(actionId: string): Promise<SessionRecovery>;
|
|
71
97
|
retry(): Promise<SessionRecovery>;
|
|
72
98
|
dispose(): Promise<void>;
|
|
73
99
|
}
|
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
import { type XStatePlaybookRuntimeFactory, type XStatePlaybookRuntimeConstruction } from '@sublang/playbook/xstate-runtime';
|
|
2
|
-
import { type DevInput
|
|
1
|
+
import { type PlaybookPlayerInput, type XStatePlaybookRuntimeFactory, type XStatePlaybookRuntimeConstruction } from '@sublang/playbook/xstate-runtime';
|
|
2
|
+
import { type DevInput } from './dev.fsm.js';
|
|
3
3
|
import type { PlaybookHostConstructionCapabilities } from '../code.playbook/playbook-captain.js';
|
|
4
4
|
import type { CaptainCallOptions, CaptainResult, JsonValue, NormalizedError, PlayerCallOptions, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookControlReceipt, PlaybookControlView, PlaybookPendingCall, PlaybookPorts, PlaybookRunResult, PlaybookRuntime, PlaybookRuntimeFactory, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookStateValue, PlaybookTraceEvent, PlaybookTraceType, PlayerResult, PlayerSessionStore } from '@sublang/playbook/runtime';
|
|
5
5
|
export type { CaptainCallOptions, CaptainResult, JsonValue, NormalizedError, PlayerCallOptions, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookControlReceipt, PlaybookControlView, PlaybookPendingCall, PlaybookPorts, PlaybookRunResult, PlaybookRuntime, PlaybookRuntimeFactory, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookStateValue, PlaybookTraceEvent, PlaybookTraceType, PlayerResult, PlayerSessionStore, };
|
|
6
6
|
export type DevPlaybookOptions = DevInput;
|
|
7
7
|
export type DevPlaybookHostCapabilities = PlaybookHostConstructionCapabilities & XStatePlaybookRuntimeConstruction<DevPlaybookOptions, object>['hostCapabilities'];
|
|
8
|
-
/**
|
|
9
|
-
* Preserve authored Markdown quote markers around every line of relayed
|
|
10
|
-
* runtime text. The generic composer preserves the marker itself; DEV's
|
|
11
|
-
* override additionally keeps a multiline value inside that quote and drops
|
|
12
|
-
* the optional relays that have no value yet.
|
|
13
|
-
*/
|
|
14
|
-
declare function composePlayerPrompt(input: PlayerInput, resuming?: boolean): string;
|
|
15
8
|
export declare const _internal: {
|
|
16
|
-
composePlayerPrompt:
|
|
9
|
+
composePlayerPrompt: (input: PlaybookPlayerInput, _identity: import("@sublang/playbook/xstate-runtime").XStatePromptIdentity, resuming?: boolean) => string;
|
|
17
10
|
VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string>;
|
|
18
11
|
UNFINISHED_FINAL_STATE_IDS: ReadonlySet<string>;
|
|
19
12
|
};
|
|
@@ -73,11 +73,6 @@ function composePlayerPrompt(input, resuming = false) {
|
|
|
73
73
|
});
|
|
74
74
|
return composePlayerContinuation(input, body, resuming);
|
|
75
75
|
}
|
|
76
|
-
export const _internal = {
|
|
77
|
-
composePlayerPrompt,
|
|
78
|
-
VERBATIM_PAYLOAD_FIELDS,
|
|
79
|
-
UNFINISHED_FINAL_STATE_IDS,
|
|
80
|
-
};
|
|
81
76
|
const runtimeSpec = {
|
|
82
77
|
label: 'DEV',
|
|
83
78
|
// DR-022 / slc/link.md: the declaration carries the value current at link
|
|
@@ -141,5 +136,10 @@ const runtimeSpec = {
|
|
|
141
136
|
unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
|
|
142
137
|
transitionEventFields: ['developmentRequest', 'answer', 'questionId'],
|
|
143
138
|
};
|
|
139
|
+
export const _internal = {
|
|
140
|
+
composePlayerPrompt: runtimeSpec.composePlayerPrompt,
|
|
141
|
+
VERBATIM_PAYLOAD_FIELDS,
|
|
142
|
+
UNFINISHED_FINAL_STATE_IDS,
|
|
143
|
+
};
|
|
144
144
|
const createPlaybookRuntime = createXStatePlaybookRuntime(devMachine, runtimeSpec);
|
|
145
145
|
export default createPlaybookRuntime;
|
|
@@ -160,12 +160,6 @@ function composePlayerPrompt(input: PlayerInput, resuming = false): string {
|
|
|
160
160
|
return composePlayerContinuation(input, body, resuming);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
export const _internal = {
|
|
164
|
-
composePlayerPrompt,
|
|
165
|
-
VERBATIM_PAYLOAD_FIELDS,
|
|
166
|
-
UNFINISHED_FINAL_STATE_IDS,
|
|
167
|
-
};
|
|
168
|
-
|
|
169
163
|
const runtimeSpec = {
|
|
170
164
|
label: 'DEV',
|
|
171
165
|
// DR-022 / slc/link.md: the declaration carries the value current at link
|
|
@@ -224,7 +218,7 @@ const runtimeSpec = {
|
|
|
224
218
|
},
|
|
225
219
|
},
|
|
226
220
|
},
|
|
227
|
-
composePlayerPrompt: (input: PlaybookPlayerInput, _identity, resuming) =>
|
|
221
|
+
composePlayerPrompt: (input: PlaybookPlayerInput, _identity, resuming?: boolean) =>
|
|
228
222
|
composePlayerPrompt(input as PlayerInput, resuming),
|
|
229
223
|
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
230
224
|
controlContextFields: [],
|
|
@@ -232,6 +226,12 @@ const runtimeSpec = {
|
|
|
232
226
|
transitionEventFields: ['developmentRequest', 'answer', 'questionId'],
|
|
233
227
|
} satisfies XStatePlaybookRuntimeSpecV3<DevPlaybookOptions>;
|
|
234
228
|
|
|
229
|
+
export const _internal = {
|
|
230
|
+
composePlayerPrompt: runtimeSpec.composePlayerPrompt,
|
|
231
|
+
VERBATIM_PAYLOAD_FIELDS,
|
|
232
|
+
UNFINISHED_FINAL_STATE_IDS,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
235
|
const createPlaybookRuntime: XStatePlaybookRuntimeFactory<
|
|
236
236
|
XStatePlaybookRuntimeConstruction<
|
|
237
237
|
DevPlaybookOptions,
|
package/reference/sdlc/pr.md
CHANGED
|
@@ -19,7 +19,9 @@ The caller supplies:
|
|
|
19
19
|
`pr` delivers a reviewed branch into the repository default branch through a GitHub pull request: it publishes the branch, waits for the pull request's checks, fixes red checks through playbook `code` no more than once, and merges.
|
|
20
20
|
It changes no files and owns no repository commit; the one fix it may request is owned by `code`.
|
|
21
21
|
The check waits, the fix publication, the merge, and the local update are mechanical steps: each runs one fixed command whose exit status alone decides its two outcomes, reads no conversation, and produces no prose.
|
|
22
|
-
`gh` infers the pull request from the checked-out branch, so
|
|
22
|
+
`gh` infers the pull request from the checked-out branch, which the run does not own: the nested `code` call suspends across Boss turns, so the checkout can change before the fix is published or the merge runs.
|
|
23
|
+
The two steps that act on the pull request itself therefore carry one runtime value — the pull request `pr` published — and refuse unless the checkout still infers exactly it.
|
|
24
|
+
That identity is reported text, so it binds as a single-quoted shell literal: the command compares it as data and never executes it as shell syntax.
|
|
23
25
|
|
|
24
26
|
When the caller gives its input, Captain shall relay the complete caller input in quotes (`>`) to Coder, along with the following instruction:
|
|
25
27
|
|
|
@@ -66,6 +68,7 @@ When the nested `code` call fails outside that authored result contract, `pr` sh
|
|
|
66
68
|
|
|
67
69
|
When `code` succeeds, Captain shall publish the fix to the pull request by running exactly the following command in the repository, with no other action and without reading its output:
|
|
68
70
|
|
|
71
|
+
> [ "$(gh pr view --json url --jq .url)" = '<pull-request-url>' ] || exit 1
|
|
69
72
|
> git push || exit 1
|
|
70
73
|
> n=0
|
|
71
74
|
> until [ "$(gh pr view --json headRefOid --jq .headRefOid 2>/dev/null)" = "$(git rev-parse HEAD)" ]; do
|
|
@@ -75,6 +78,7 @@ When `code` succeeds, Captain shall publish the fix to the pull request by runni
|
|
|
75
78
|
> done
|
|
76
79
|
|
|
77
80
|
The publication has exactly two outcomes decided by the exit status alone: fix published on status zero, once the pull request's head is the pushed commit, and fix not published otherwise.
|
|
81
|
+
The command pushes nothing until the checked-out branch infers the pull request `pr` published, so a checkout that changed while `code` ran publishes to no other branch.
|
|
78
82
|
Fix not published is an authored failure that leaves the pull request open.
|
|
79
83
|
|
|
80
84
|
When the fix is published, Captain shall wait for the pull request's checks again by running exactly the same command as the first wait in the repository, with no other action and without reading its output:
|
|
@@ -92,11 +96,20 @@ Checks still failing is an authored failure that leaves the pull request open; t
|
|
|
92
96
|
|
|
93
97
|
When the checks pass, before or after the one fix attempt, Captain shall merge the pull request by running exactly the following command in the repository, with no other action and without reading its output:
|
|
94
98
|
|
|
95
|
-
> gh pr
|
|
99
|
+
> pr=$(gh pr view --json url --jq .url) || exit 1
|
|
100
|
+
> [ "$pr" = '<pull-request-url>' ] || exit 1
|
|
101
|
+
> base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name) || exit 1
|
|
102
|
+
> [ -n "$base" ] || exit 1
|
|
103
|
+
> [ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" = "$base" ] || exit 1
|
|
104
|
+
> gh pr merge --merge --delete-branch --match-head-commit "$(git rev-parse HEAD)" || exit 1
|
|
105
|
+
> [ "$(gh pr view "$pr" --json state --jq .state)" = MERGED ] || exit 1
|
|
106
|
+
> [ "$(git branch --show-current)" = "$base" ]
|
|
96
107
|
|
|
97
|
-
The merge creates a merge commit on the repository default branch,
|
|
108
|
+
The merge creates a merge commit on the repository default branch, requests deletion of the remote and local branch, and checks out the local default branch; GitHub closes the linked issue on merge.
|
|
98
109
|
The merge has exactly two outcomes decided by the exit status alone: merged on status zero and merge refused otherwise.
|
|
99
|
-
|
|
110
|
+
The command requires the inferred pull request to be the one `pr` published and to target the repository default branch before the irreversible merge, and confirms that same pull request's merged state and the default-branch checkout after the merge command succeeds; a queued pull request is not a merged result.
|
|
111
|
+
Merge refused is an authored failure that leaves the pull request in the state GitHub reports: the checkout no longer infers the published pull request, the pull request targets another branch, GitHub refused the merge for a conflict, a branch protection, a forbidden merge method, or a head that moved, or the merge may have landed while its confirmation, the local switch to the default branch, or the branch deletion failed.
|
|
112
|
+
A refused merge does not establish that the pull request is unmerged, so `pr` reports it as an unconfirmed merge rather than as not merged, and no outcome claims a branch was deleted, because `gh` skips the remote deletion for a pull request from another repository or one already merged and exits zero anyway.
|
|
100
113
|
|
|
101
114
|
When the pull request is merged, Captain shall bring the local default branch to the merged head by running exactly the following command in the repository, with no other action and without reading its output:
|
|
102
115
|
|